import { createJob, emitJob, type Job } from '~/server/utils/jobs' import { ensureComfyReady } from '~/server/utils/comfyLifecycle' import { assertImageScaleToTotalPixelsNode, getComfyHost, uploadImage, queuePrompt, purgeComfyArtifacts } from '~/server/utils/comfy' import { withImageComfyHost, waitForImageEdit, downloadEditedImage } from '~/server/utils/imageComfy' import { buildImageV2Workflow, resolveKreaGenerateAssets } from '~/server/utils/imageWorkflowV2' import { ensureComfyLoraNames } from '~/server/utils/loras' import { imageDimensions } from '~/server/utils/resolution' import { emitChainJob } from '~/server/utils/watch' import { saveStill } from '~/server/utils/library' import { 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 sourceStillId?: string referenceStillId?: string } 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.') } 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' 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 }) const uploaded = generate || !params.image ? null : await uploadImage(params.image, job.id) const uploadedRef = generate || params.mode === 'refine' ? null : params.reference ? await uploadImage({ ...params.reference, filename: `ref_${params.reference.filename || 'image_b.png'}` }, job.id) : null const uploadedMask = !generate && params.mode === 'refine' && params.mask ? await uploadImage({ ...params.mask, filename: `mask_${params.mask.filename || 'refine-mask.png'}` }, job.id) : null 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 }) } await ensureComfyLoraNames('image') if (!generate) await assertImageScaleToTotalPixelsNode() const krea = engine === 'krea' ? await resolveKreaGenerateAssets() : null const built = buildImageV2Workflow({ mode: params.mode, engine, task: params.task, prompt: params.prompt, negative: params.negative, imageAName: uploaded?.name, imageBName: uploadedRef?.name, maskName: uploadedMask?.name, strength: params.strength, snofsModel: params.snofsModel, snofsClip: params.snofsClip, consistencyModel: params.consistencyModel, consistencyClip: params.consistencyClip, steps: params.steps, cfg: params.cfg, seed: params.seed, megapixels: params.megapixels, width: params.width, height: params.height, turbo: params.turbo === true, filenamePrefix: `aigen_v2_${job.id.slice(0, 8)}`, kreaUnetName: krea?.unet, kreaClipName: krea?.clip, kreaVaeName: krea?.vae, kreaConceptLora: krea?.conceptLora }) 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, 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: output.filename, data: buffer, width: size?.width || 0, height: size?.height || 0, hideInput: job.hideThumbnail === true, role: 'output', name: library.name || undefined, prompt: params.prompt, settings: { kind: 'imagev2', mode: params.mode, engine, task: params.task, negative: params.negative, snofsModel: params.snofsModel, snofsClip: params.snofsClip, consistencyModel: params.consistencyModel, consistencyClip: params.consistencyClip, steps: params.steps, cfg: params.cfg, megapixels: params.mode === 'generate' ? undefined : params.megapixels, 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, seed: params.seed, sourceStillId: params.sourceStillId || library.stillId, referenceStillId: params.referenceStillId } }) job.stillId = still?.id await purgeComfyArtifacts({ video: { filename: output.filename, subfolder: output.subfolder, type: output.type }, imageName: uploaded?.name, imageSubfolder: uploaded?.subfolder, extraImageNames: [uploadedRef?.name, uploadedMask?.name].filter((name): name is string => Boolean(name)), promptId: job.promptId }) job.status = 'complete' emitChainJob(job, { type: 'complete', message: 'Klein 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) } }