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 { buildEditWorkflow } from '~/server/utils/imageWorkflow' import { ensureComfyLoraNames } 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' export type EditImageFile = { filename: string; data: Buffer; type?: string } export type EditRunParams = { image: EditImageFile reference: EditImageFile | null prompt: string passes: { prompt: string }[] negative: string steps: number seed: number cfg: number aspect?: string loraName?: string loraStack?: import('~/utils/loras').LoraStackItem[] scaleToTotalPixels?: boolean scaleMegapixels?: number passMode?: 'batch' | 'chain' } export async function runEdit(job: Job, params: EditRunParams) { const library = job.library if (!library) throw new Error('Edit job is missing library metadata') const prompts = [ library.promptMid || restorePromptParts(params.prompt, { pre: library.promptPre, prompt: params.prompt, post: library.promptPost }).prompt, ...params.passes.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 { applyImageFill } = await import('~/server/utils/imagePad') const { composeFillPrompt } = await import('~/utils/imageAspect') const filled = await applyImageFill({ image: params.image, aspect: params.aspect || 'auto' }) if (filled.padded) { emitChainJob(job, { type: 'status', message: `Filling to ${filled.aspect} without stretching...`, progress: 7 }) } let current = filled.image let parentStillId: string | undefined 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 mid = (batch || index === 0) ? composeFillPrompt(prompts[index], filled.padded) : prompts[index] const prompt = wrappedPromptForComfy(library, mid, index === 0 ? params.prompt : mid) const last = index === prompts.length - 1 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 }) } const seed = index === 0 ? params.seed : Math.floor(Math.random() * 2_147_483_647) library.prompt = mid library.seed = seed library.chainIndex = index library.chainStep = index + 1 library.chainLabel = chainTotal > 1 ? `${unit} ${index + 1}` : undefined const reference = index === 0 ? params.reference : null const dual = Boolean(reference) const passName = stillChainName(library.name || '', index) emitChainJob(job, { type: 'status', message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...', progress: 8 }) const uploaded = await uploadImage(current, job.id) const uploadedRef = reference ? await uploadImage({ ...reference, filename: `ref_${reference.filename || 'image2.png'}` }, job.id) : null if (job.status === 'cancelled') throw new Error('Job interrupted.') emitChainJob(job, { type: 'status', message: dual ? 'Queueing Flux.2 Klein two-image edit on Beast...' : 'Queueing Flux.2 Klein edit on Beast...', progress: 12 }) await ensureComfyLoraNames('image') if (params.scaleToTotalPixels) await assertImageScaleToTotalPixelsNode() const graph = buildEditWorkflow({ prompt, negative: params.negative, imageName: uploaded.name, referenceImageName: uploadedRef?.name, steps: params.steps, seed, cfg: params.cfg, filenamePrefix: `${comfyJobPrefix(job.id)}_p${index + 1}`, loraStack: params.loraStack || params.loraName, scaleToTotalPixels: params.scaleToTotalPixels, scaleMegapixels: params.scaleMegapixels }) const queued = await queuePrompt(graph, job.clientId) job.promptId = queued.prompt_id job.status = 'running' emitChainJob(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) => { 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 edited 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: mid, promptPre: library.promptPre, promptPost: library.promptPost, familyId: library.familyId, parentStillId: batch ? library.stillId : parentStillId, chainIndex: index, settings: { kind: 'image', steps: params.steps, cfg: params.cfg, negative: params.negative, seed, scaleToTotalPixels: params.scaleToTotalPixels === true, scaleMegapixels: params.scaleMegapixels, aspect: params.aspect || 'auto', loraName: params.loraName, loraStack: params.loraStack, sourceStillId: library.stillId } }) 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: uploadedRef?.name ? [uploadedRef.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 ? (batch ? 'Edit batch finished on Beast' : 'Edit chain finished on Beast') : 'Edit 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) } } export function createEditLiveJob(params: { steps: number hideThumbnail: boolean library: NonNullable }) { const job = createJob() job.kind = 'edit' job.maxStep = params.steps job.hideThumbnail = params.hideThumbnail job.library = params.library return job }