Files
aigen/server/utils/imageChainV2.ts
T

409 lines
16 KiB
TypeScript

import { normalizeImageIterations, resolveImageIteration } from '~/utils/imageIterations'
import { createJob, emitJob, type Job } from '~/server/utils/jobs'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import { assertImageScaleToTotalPixelsNode, comfyImagePrefix, getComfyHost, uploadImage, queuePrompt } from '~/server/utils/comfy'
import { withImageComfyHost, waitForImageEdit, downloadEditedImage, purgeImageComfyArtifacts } from '~/server/utils/imageComfy'
import { buildImageV2Workflow, IMAGE_V2_NODE_LABELS, resolveKreaGenerateAssets } from '~/server/utils/imageWorkflowV2'
import { ensureComfyLoraNames, persistLoraFields } from '~/server/utils/loras'
import { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch'
import { saveStill, stillChainName } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames'
import { restorePromptParts, wrappedPromptForComfy } from '~/utils/promptParts'
import { isXaigenStudio } from '~/utils/videoModels'
import {
imageV2GraphMode,
imageV2StackSpecials,
mergeLegacyImageV2Loras,
parseImageV2Engine,
type ImageV2Engine,
type ImageV2Mode,
type ImageV2Task
} from '~/utils/imageV2'
import type { EditImageFile } from '~/server/utils/imageChain'
export type EditV2RunParams = {
mode: ImageV2Mode
engine?: ImageV2Engine
task: ImageV2Task
image?: EditImageFile | null
reference: EditImageFile | null
mask?: EditImageFile | null
prompt: string
negative: string
steps: number
seed: number
cfg: number
snofsModel: number
snofsClip: number
consistencyModel: number
consistencyClip: number
megapixels: number
strength?: number
width?: number
height?: number
turbo?: boolean
aspect?: string
sourceStillId?: string
referenceStillId?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
passes?: import('~/utils/imageIterations').ImageIteration[]
passMode?: 'batch' | 'chain'
}
export async function runEditV2(job: Job, params: EditV2RunParams) {
const library = job.library
if (!library) throw new Error('Edit job is missing library metadata')
const graphMode = imageV2GraphMode(params.mode, {
hasImage: Boolean(params.image),
hasReference: Boolean(params.reference)
})
if (graphMode === 'refine' && !params.mask) {
throw new Error('Refine requires a mask. Refusing to fall back to Edit.')
}
if (graphMode === 'compose' && !params.reference) {
throw new Error('Compose requires image B. Refusing to fall back to one-image generation.')
}
if (graphMode === 'edit' && params.reference && params.mode !== 'iterate') {
throw new Error('Edit mode takes one image. Use Compose for two stills.')
}
if (graphMode !== 'generate' && !params.image) {
throw new Error('This v2 mode requires still A.')
}
const canChain = params.mode === 'iterate' || params.mode === 'edit' || params.mode === 'compose'
const extraPasses = canChain
? normalizeImageIterations(params.passes)
: []
const prompts = [
library.promptMid || restorePromptParts(params.prompt, {
pre: library.promptPre,
prompt: params.prompt,
post: library.promptPost
}).prompt,
...extraPasses.map(item => item.prompt || library.promptMid || library.prompt || params.prompt)
]
const chainTotal = prompts.length
// Text-only iterate is always independent generations. With a still, honor batch vs chain.
const batch = graphMode === 'generate' || params.passMode !== 'chain'
const unit = params.mode === 'iterate' ? 'Iteration' : (batch ? 'Batch' : 'Pass')
library.chainTotal = chainTotal
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
try {
// Keep the studio slot across all iterations, including upload/save gaps.
library.chainContinuing = true
await ensureComfyReady((status) => {
emitChainJob(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'
})
}, { skipBusyWait: true })
if (job.status === 'cancelled') throw new Error('Job interrupted.')
job.imageComfyHost = getComfyHost()
await withImageComfyHost(job.imageComfyHost, async () => {
const generate = graphMode === 'generate'
const engine = parseImageV2Engine(params.engine, 'flux')
const engineLabel = engine === 'krea' ? 'Krea' : 'Flux'
const { applyImageFill } = await import('~/server/utils/imagePad')
const { composeFillPrompt } = await import('~/utils/imageAspect')
let current = params.image || null
let startPadded = false
let mask = params.mask
let parentStillId: string | undefined
let uploadedRef: { name: string } | null = null
let uploadedMask: { name: string } | null = null
await ensureComfyLoraNames('image')
if (!generate && params.megapixels > 0) await assertImageScaleToTotalPixelsNode()
const krea = engine === 'krea' ? await resolveKreaGenerateAssets() : null
if (!generate && graphMode === 'compose' && params.reference) {
uploadedRef = await uploadImage({
...params.reference,
filename: `ref_${params.reference.filename || 'image_b.png'}`
}, job.id)
}
for (let index = 0; index < prompts.length; index++) {
if (job.status === 'cancelled') throw new Error('Job interrupted.')
if (index > 0 && library.stopAfterCurrent === true) break
const last = index === prompts.length - 1
const pass = index > 0 ? extraPasses[index - 1] : undefined
const { steps, cfg, seed, strength } = resolveImageIteration(params, pass)
const turbo = pass?.steps !== undefined || pass?.cfg !== undefined ? false : params.turbo === true
const appliedStack = mergeLegacyImageV2Loras(pass?.loraStack ?? params.loraStack ?? library.loraStack, {
engine,
mode: graphMode,
xaigen: isXaigenStudio(),
snofsModel: pass?.loraStack !== undefined ? 0 : params.snofsModel,
snofsClip: pass?.loraStack !== undefined ? 0 : params.snofsClip,
consistencyModel: pass?.loraStack !== undefined ? 0 : params.consistencyModel,
consistencyClip: pass?.loraStack !== undefined ? 0 : params.consistencyClip,
kreaConcept: krea?.conceptLora
})
const appliedSpecials = imageV2StackSpecials(appliedStack, engine)
const snofsModel = appliedSpecials.snofs?.strengthModel ?? (pass?.loraStack !== undefined ? 0 : params.snofsModel)
const snofsClip = appliedSpecials.snofs?.strengthClip ?? (pass?.loraStack !== undefined ? 0 : params.snofsClip)
const consistencyModel = appliedSpecials.consistency?.strengthModel ?? (pass?.loraStack !== undefined ? 0 : params.consistencyModel)
const consistencyClip = appliedSpecials.consistency?.strengthClip ?? (pass?.loraStack !== undefined ? 0 : params.consistencyClip)
library.steps = steps
library.cfg = cfg
job.maxStep = steps
library.prompt = prompts[index]
library.seed = seed
library.chainIndex = index
library.chainStep = index + 1
library.chainLabel = chainTotal > 1 ? `${unit} ${index + 1}` : undefined
const passName = stillChainName(library.name || '', index)
let prompt = prompts[index]
let image = current
let padded = false
if (index > 0) {
await ensureComfyReady((status) => {
emitChainJob(job, {
type: status.state === 'busy' ? 'busy' : 'status',
message: status.message,
progress: status.state === 'online' ? 4 : 2,
busy: status.state === 'busy'
})
}, { skipBusyWait: true })
}
emitChainJob(job, {
type: 'status',
message: generate
? `Queueing ${engineLabel} iterate generate on Beast...`
: graphMode === 'refine'
? 'Uploading canvas and mask to Beast...'
: graphMode === 'compose' ? 'Uploading stills A and B to Beast...' : 'Uploading still A to Beast...',
progress: generate ? 12 : 8
})
if (index === 0 && !generate && image) {
const filled = await applyImageFill({
image,
mask: graphMode === 'refine' ? mask : null,
aspect: params.aspect || 'auto'
})
image = filled.image
current = filled.image
if (filled.mask) {
mask = filled.mask
uploadedMask = await uploadImage({
...filled.mask,
filename: `mask_${filled.mask.filename || 'refine-mask.png'}`
}, job.id)
}
padded = filled.padded
startPadded = filled.padded
prompt = composeFillPrompt(prompt, padded)
if (padded) {
emitChainJob(job, {
type: 'status',
message: `Filling to ${filled.aspect} without stretching...`,
progress: 7
})
}
} else if (batch && startPadded) {
prompt = composeFillPrompt(prompt, true)
}
if (index === 0 && graphMode === 'refine' && mask && !uploadedMask) {
uploadedMask = await uploadImage({
...mask,
filename: `mask_${mask.filename || 'refine-mask.png'}`
}, job.id)
}
const uploaded = generate || !image ? null : await uploadImage(image, job.id)
if (job.status === 'cancelled') throw new Error('Job interrupted.')
if (!generate) {
emitChainJob(job, {
type: 'status',
message: params.mode === 'iterate'
? `Queueing ${engineLabel} iterate (${graphMode}) on Beast...`
: graphMode === 'refine'
? `Queueing ${engineLabel} v2 refine on Beast...`
: graphMode === 'compose'
? `Queueing ${engineLabel} v2 compose (${params.task}) on Beast...`
: `Queueing ${engineLabel} v2 edit on Beast...`,
progress: 12
})
}
const built = buildImageV2Workflow({
mode: graphMode,
engine,
task: generate ? 't2i' : params.task,
prompt: wrappedPromptForComfy(library, prompt, index === 0 ? params.prompt : prompt),
negative: params.negative,
imageAName: uploaded?.name,
imageBName: uploadedRef?.name,
maskName: uploadedMask?.name,
strength,
snofsModel,
snofsClip,
consistencyModel,
consistencyClip,
steps,
cfg,
seed,
megapixels: params.megapixels,
width: params.width,
height: params.height,
aspect: params.aspect,
turbo,
filenamePrefix: chainTotal > 1 ? `${comfyImagePrefix()}_${comfyJobPrefix(job.id)}_p${index + 1}` : `${comfyImagePrefix()}_${comfyJobPrefix(job.id)}`,
kreaUnetName: krea?.unet,
kreaClipName: krea?.clip,
kreaVaeName: krea?.vae,
kreaConceptLora: krea?.conceptLora,
loraStack: appliedStack
})
const queued = await queuePrompt(built.graph, job.clientId)
job.promptId = queued.prompt_id
job.status = 'running'
emitChainJob(job, {
type: 'status',
message: `Running ${built.workflowFile}...`,
progress: 18,
maxStep: steps
})
const output = await waitForImageEdit({
promptId: queued.prompt_id,
clientId: job.clientId,
timeoutMs: 10 * 60 * 1000,
engineLabel,
nodeLabel: (node) => {
if (node === '15' || node === '19') return `Sampling ${engineLabel}`
return IMAGE_V2_NODE_LABELS[node]
},
onProgress: (event) => {
emitChainJob(job, {
type: 'status',
message: event.message,
progress: event.progress,
step: event.step,
maxStep: event.maxStep || steps,
node: event.node
})
},
isCancelled: () => job.status === 'cancelled'
})
emitChainJob(job, { type: 'status', message: `Saving ${engineLabel} v2 still...`, progress: 94 })
job.saving = true
const buffer = await downloadEditedImage(output)
const size = imageDimensions(buffer)
const still = await saveStill({
ownerKey: library.ownerKey,
folderId: library.folderId,
filename: passName ? `${passName}.png` : output.filename,
data: buffer,
width: size?.width || 0,
height: size?.height || 0,
hideInput: job.hideThumbnail === true,
role: 'output',
name: passName || undefined,
prompt,
promptPre: library.promptPre,
promptPost: library.promptPost,
familyId: library.familyId,
parentStillId: batch ? (params.sourceStillId || library.stillId) : parentStillId,
chainIndex: index,
settings: {
kind: 'imagev2',
mode: params.mode,
engine,
task: generate ? 't2i' : params.task,
negative: params.negative,
snofsModel,
snofsClip,
consistencyModel,
consistencyClip,
steps,
cfg,
megapixels: generate || !(params.megapixels > 0) ? undefined : params.megapixels,
scaleToTotalPixels: generate ? undefined : params.megapixels > 0,
turbo,
strength: generate ? undefined : strength,
width: generate ? params.width : undefined,
height: generate ? params.height : undefined,
aspect: params.aspect || 'auto',
seed,
sourceStillId: params.sourceStillId || library.stillId,
referenceStillId: params.referenceStillId,
...persistLoraFields(appliedStack)
}
})
job.stillId = still?.id
if (!batch) parentStillId = still?.id
await purgeImageComfyArtifacts({
output: { filename: output.filename, subfolder: output.subfolder, type: output.type },
inputName: uploaded?.name,
inputSubfolder: uploaded?.subfolder,
extraInputNames: last
? [uploadedRef?.name, uploadedMask?.name].filter((name): name is string => Boolean(name))
: [],
promptId: job.promptId
})
job.saving = false
if (!last) {
emitChainJob(job, {
type: 'checkpoint',
message: `${unit} ${index + 1} of ${chainTotal} saved`,
progress: 100,
stillId: still?.id,
hideThumbnail: job.hideThumbnail,
folderLocked: library.folderLocked
})
if (!batch) {
current = {
filename: still?.filename || `pass_${index + 1}.png`,
data: buffer,
type: 'image/png'
}
}
if (library.stopAfterCurrent === true) break
continue
}
job.status = 'complete'
emitChainJob(job, {
type: 'complete',
message: chainTotal > 1
? `${engineLabel} ${params.mode === 'iterate' ? 'iterate' : `v2 ${batch ? 'batch' : 'chain'}`} finished on Beast`
: `${engineLabel} v2 finished on Beast`,
progress: 100,
stillId: still?.id,
filename: output.filename,
subfolder: output.subfolder,
mediaType: 'image',
hideThumbnail: job.hideThumbnail,
folderLocked: library.folderLocked
})
}
})
} catch (error) {
if (job.status !== 'error' && job.status !== 'cancelled') {
const message = error instanceof Error ? error.message : String(error)
job.status = 'error'
job.error = message
emitJob(job, { type: 'error', error: message, message })
}
} finally {
job.saving = false
library.chainContinuing = false
const { onLiveVideoSettled } = await import('~/server/utils/studioQueue')
await onLiveVideoSettled(job)
}
}