import { createJob, emitJob, type Job } from '~/server/utils/jobs' import { ensureComfyReady } from '~/server/utils/comfyLifecycle' import { assertImageScaleToTotalPixelsNode, 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 { joinPromptParts } from '~/utils/promptParts' import { isXaigenStudio } from '~/utils/videoModels' import { 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?: { prompt: string }[] 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') if (params.mode === 'refine' && !params.mask) { throw new Error('Refine requires a mask. Refusing to fall back to Edit.') } if (params.mode === 'compose' && !params.reference) { throw new Error('Compose requires image B. Refusing to fall back to one-image generation.') } if (params.mode === 'edit' && params.reference) { throw new Error('Edit mode takes one image. Use Compose for two stills.') } if (params.mode !== 'generate' && !params.image) { throw new Error('This v2 mode requires still A.') } const canChain = params.mode === 'edit' || params.mode === 'compose' const extraPasses = canChain ? (params.passes || []).map(item => ({ prompt: String(item.prompt || '').trim() })).filter(item => item.prompt) : [] const prompts = [params.prompt, ...extraPasses.map(item => item.prompt)] const chainTotal = prompts.length const batch = params.passMode !== 'chain' const unit = batch ? 'Batch' : 'Pass' library.chainTotal = chainTotal library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId try { 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 = params.mode === '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 const appliedStack = mergeLegacyImageV2Loras(params.loraStack?.length ? params.loraStack : library.loraStack, { engine, mode: params.mode, xaigen: isXaigenStudio(), snofsModel: params.snofsModel, snofsClip: params.snofsClip, consistencyModel: params.consistencyModel, consistencyClip: params.consistencyClip, kreaConcept: krea?.conceptLora }) const appliedSpecials = imageV2StackSpecials(appliedStack, engine) const snofsModel = appliedSpecials.snofs?.strengthModel ?? params.snofsModel const snofsClip = appliedSpecials.snofs?.strengthClip ?? params.snofsClip const consistencyModel = appliedSpecials.consistency?.strengthModel ?? params.consistencyModel const consistencyClip = appliedSpecials.consistency?.strengthClip ?? params.consistencyClip if (!generate && params.mode === '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 seed = index === 0 ? params.seed : Math.floor(Math.random() * 2_147_483_647) 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} generate on Beast...` : params.mode === 'refine' ? 'Uploading canvas and mask to Beast...' : params.mode === '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: params.mode === '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 && params.mode === '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 === 'refine' ? `Queueing ${engineLabel} v2 refine on Beast...` : params.mode === 'compose' ? `Queueing ${engineLabel} v2 compose (${params.task}) on Beast...` : `Queueing ${engineLabel} v2 edit on Beast...`, progress: 12 }) } const built = buildImageV2Workflow({ mode: params.mode, engine, task: params.task, prompt: joinPromptParts(library.promptPre, prompt, library.promptPost), negative: params.negative, imageAName: uploaded?.name, imageBName: uploadedRef?.name, maskName: uploadedMask?.name, strength: params.strength, snofsModel, snofsClip, consistencyModel, consistencyClip, steps: params.steps, cfg: params.cfg, seed, megapixels: params.megapixels, width: params.width, height: params.height, aspect: params.aspect, turbo: params.turbo === true, filenamePrefix: chainTotal > 1 ? `${comfyJobPrefix(job.id)}_p${index + 1}` : 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: params.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 || params.steps, node: event.node }) }, isCancelled: () => job.status === 'cancelled' }) emitChainJob(job, { type: 'status', message: `Saving ${engineLabel} v2 still...`, progress: 94 }) 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: params.task, negative: params.negative, snofsModel, snofsClip, consistencyModel, consistencyClip, steps: params.steps, cfg: params.cfg, megapixels: params.mode === 'generate' || !(params.megapixels > 0) ? undefined : params.megapixels, scaleToTotalPixels: params.mode === 'generate' ? undefined : params.megapixels > 0, turbo: params.turbo === true, strength: params.mode === 'generate' ? undefined : params.strength, width: params.mode === 'generate' ? params.width : undefined, height: params.mode === '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 }) 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} 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 { const { onLiveVideoSettled } = await import('~/server/utils/studioQueue') await onLiveVideoSettled(job) } }