import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue' import { comfyConfigured } from '~/server/utils/comfy' import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect' import { imageDimensions } from '~/server/utils/resolution' import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImagePassMode } from '~/utils/generationPresets' import { IMAGE_V2_CFG_DEFAULT, IMAGE_V2_CONSISTENCY_CLIP, IMAGE_V2_CONSISTENCY_MODEL, IMAGE_V2_STEPS_DEFAULT, IMAGE_V2_TURBO_CFG, IMAGE_V2_TURBO_STEPS, IMAGE_V2_KREA_CFG, IMAGE_V2_KREA_COMPOSE_DENOISE, IMAGE_V2_KREA_EDIT_DENOISE, IMAGE_V2_KREA_STEPS, IMAGE_V2_DENOISE_DEFAULT, clampImageV2Denoise, clampImageV2Size, clampImageV2Strength, imageV2StackSpecials, imageV2WorkflowFile, parseImageV2Engine, parseImageV2Mode, parseImageV2Task, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2' import { isXaigenStudio } from '~/utils/videoModels' import { joinPromptParts } from '~/utils/promptParts' import { listStudioLoras, parsePostedLoraStack, persistLoraFields, assertImageV2LoraStack } from '~/server/utils/loras' import { getStill, rememberInputStill, stillPath } from '~/server/utils/library' import { existsSync, readFileSync } from 'node:fs' type ImageFile = { filename: string; data: Buffer; type?: string } function parsePasses(raw: unknown) { if (!raw) return [] as { prompt: string }[] let parsed = raw if (typeof raw === 'string') { try { parsed = JSON.parse(raw) } catch { return [] } } if (!Array.isArray(parsed)) return [] return parsed .map((item: { prompt?: unknown }) => ({ prompt: String(item?.prompt || '').trim() })) .filter(item => item.prompt) } function parseBool(raw: unknown) { return raw === true || raw === 'true' || raw === '1' || raw === 1 } function isHttpUrl(value: string) { return /^https?:\/\//i.test(value) } async function fileFromUrl(url: string): Promise { const res = await fetch(url, { signal: AbortSignal.timeout(20_000) }) if (!res.ok) { throw createError({ statusCode: 400, statusMessage: `Could not fetch image (${res.status})` }) } const mime = String(res.headers.get('content-type') || 'image/png').split(';')[0] if (!/^image\//i.test(mime)) { throw createError({ statusCode: 400, statusMessage: 'image_a / image_b / mask URL must be an image' }) } const data = Buffer.from(await res.arrayBuffer()) if (data.length > 40 * 1024 * 1024) { throw createError({ statusCode: 400, statusMessage: 'Image is too large' }) } const ext = mime.includes('jpeg') || mime.includes('jpg') ? '.jpg' : mime.includes('webp') ? '.webp' : '.png' return { filename: `remote${ext}`, data, type: mime } } function fileFromStill(ownerKey: string, id: string): ImageFile { const still = getStill(ownerKey, id) const path = stillPath(ownerKey, still.id) if (!existsSync(path)) { throw createError({ statusCode: 400, statusMessage: 'That still is missing from the library' }) } return { filename: still.name ? `${still.name}.png` : (still.filename || `${still.id}.png`), data: readFileSync(path), type: 'image/png' } } async function resolveImageRef(ownerKey: string, raw: unknown, uploaded: ImageFile | null) { if (uploaded) return uploaded const value = String(raw || '').trim() if (!value || value === 'null') return null if (isHttpUrl(value)) return fileFromUrl(value) return fileFromStill(ownerKey, value) } function readMultipart(parts: Array<{ name?: string; filename?: string; type?: string; data?: Buffer }> | null) { const fields: Record = {} let imageA: ImageFile | null = null let imageB: ImageFile | null = null let mask: ImageFile | null = null for (const part of parts || []) { if ((part.name === 'image_a' || part.name === 'image') && part.filename && part.data?.length) { imageA = { filename: part.filename, data: part.data, type: part.type } } else if ((part.name === 'image_b' || part.name === 'image2') && part.filename && part.data?.length) { imageB = { filename: part.filename, data: part.data, type: part.type } } else if (part.name === 'mask' && part.filename && part.data?.length) { mask = { filename: part.filename, data: part.data, type: part.type } } else if (part.name && part.data) { fields[part.name] = part.data.toString('utf8') } } return { fields, imageA, imageB, mask } } export default defineEventHandler(async (event) => { const contentType = String(getHeader(event, 'content-type') || '') let fields: Record = {} let uploadedA: ImageFile | null = null let uploadedB: ImageFile | null = null let uploadedMask: ImageFile | null = null if (contentType.includes('multipart/form-data')) { const form = await readMultipartFormData(event).catch(() => null) const parsed = readMultipart(form || []) fields = parsed.fields uploadedA = parsed.imageA uploadedB = parsed.imageB uploadedMask = parsed.mask } else { fields = await readBody>(event).catch(() => ({})) } const mode = parseImageV2Mode(fields.mode) if (!mode) { throw createError({ statusCode: 400, statusMessage: 'mode must be edit, compose, refine, or generate' }) } const task = parseImageV2Task( fields.task, mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene' ) const promptMid = String(fields.prompt || '').trim() const promptPre = String(fields.promptPre || '').trim() const promptPost = String(fields.promptPost || '').trim() const prompt = joinPromptParts(promptPre, promptMid, promptPost) const extraPasses = parsePasses(fields.passes) const passMode = parseImagePassMode(fields.passMode) const aspect = parseImageAspect(fields.aspect) if (!promptMid && (mode === 'generate' || aspect === 'auto')) { throw createError({ statusCode: 400, statusMessage: 'A prompt is required' }) } if (extraPasses.length && (mode === 'refine' || mode === 'generate')) { throw createError({ statusCode: 400, statusMessage: 'Extra passes are only for Edit and Compose. Refine and Generate stay a single run.' }) } if (extraPasses.some(item => !item.prompt)) { throw createError({ statusCode: 400, statusMessage: 'Each extra pass needs a prompt' }) } if (!comfyConfigured()) { throw createError({ statusCode: 503, statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.' }) } if (mode === 'edit' && (task === 'identity' || task === 'outfit' || task === 'face_lock')) { throw createError({ statusCode: 400, statusMessage: `${task} requires Compose and image B. Edit is one image only.` }) } const ownerKey = libraryOwnerKey(event) const imageA = mode === 'generate' ? null : await resolveImageRef(ownerKey, fields.image_a, uploadedA) const imageB = mode === 'refine' || mode === 'generate' ? null : await resolveImageRef(ownerKey, fields.image_b, uploadedB) const mask = mode === 'refine' ? await resolveImageRef(ownerKey, fields.mask, uploadedMask) : null if (mode !== 'generate' && !imageA) { throw createError({ statusCode: 400, statusMessage: 'image_a is required' }) } if (mode === 'refine' && !mask) { throw createError({ statusCode: 400, statusMessage: 'Refine requires a mask. Refusing to fall back to Edit.' }) } if (mode === 'edit' && imageB) { throw createError({ statusCode: 400, statusMessage: 'Edit mode takes one image. Use Compose for two stills.' }) } if (mode === 'compose' && !imageB) { throw createError({ statusCode: 400, statusMessage: 'Compose requires image_b. Refusing to fall back to one-image generation.' }) } const library = publicLibrary(event) const folderId = library.folders.some(folder => folder.id === String(fields.folderId || '')) ? String(fields.folderId) : library.folders[0]?.id if (!folderId) { throw createError({ statusCode: 400, statusMessage: 'Create a library folder before generating' }) } assertFolderExists(event, folderId) const destFolder = library.folders.find(folder => folder.id === folderId) const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked) const hideInput = parseBool(fields.hideInput) const hideThumbnail = parseBool(fields.hideThumbnail) const requestedEngine = parseImageV2Engine(fields.engine, 'flux') const engine = requestedEngine const turbo = parseBool(fields.turbo) const steps = engine === 'krea' ? clampImageSteps(fields.steps, IMAGE_V2_KREA_STEPS) : turbo ? IMAGE_V2_TURBO_STEPS : clampImageSteps(fields.steps, IMAGE_V2_STEPS_DEFAULT) const cfg = engine === 'krea' ? clampImageCfg(fields.cfg, IMAGE_V2_KREA_CFG) : turbo ? IMAGE_V2_TURBO_CFG : clampImageCfg(fields.cfg, IMAGE_V2_CFG_DEFAULT) const megapixels = mode === 'generate' ? 0 : fields.scaleToTotalPixels == null ? clampImageScaleMegapixels(fields.megapixels ?? fields.scaleMegapixels, 1) : parseBool(fields.scaleToTotalPixels) ? clampImageScaleMegapixels(fields.megapixels ?? fields.scaleMegapixels, 1) : 0 const seed = fields.seed && String(fields.seed) !== 'random' ? Number(fields.seed) : Math.floor(Math.random() * 2_147_483_647) const generateSize = mode === 'generate' ? generateSizeForAspect(aspect) : clampImageV2Size(fields.width, fields.height, fields.aspect) const size = mode === 'generate' ? { width: generateSize.width, height: generateSize.height } : imageDimensions(imageA!.data) const v2Mode = mode as ImageV2Mode const v2Task = ( mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : mode === 'edit' ? 'scene' : task ) as ImageV2Task const refineStrength = mode === 'generate' ? undefined : engine === 'krea' || mode === 'refine' ? clampImageV2Denoise( fields.strength, mode === 'compose' ? IMAGE_V2_KREA_COMPOSE_DENOISE : mode === 'edit' ? IMAGE_V2_KREA_EDIT_DENOISE : IMAGE_V2_DENOISE_DEFAULT ) : undefined const generateConsistencyModel = clampImageV2Strength( fields.consistency_model, mode === 'generate' || engine === 'krea' ? 0 : IMAGE_V2_CONSISTENCY_MODEL ) const generateConsistencyClip = clampImageV2Strength( fields.consistency_clip, mode === 'generate' || engine === 'krea' ? 0 : IMAGE_V2_CONSISTENCY_CLIP ) const conceptAllowed = isXaigenStudio() const generateSnofsFallback = 0 const generateSnofsClipFallback = 0 await listStudioLoras() const loraStack = assertImageV2LoraStack(parsePostedLoraStack(fields.loraStack || fields.lora, 'image'), engine) const stackSpecials = imageV2StackSpecials(loraStack, engine) const snofsModel = conceptAllowed ? (stackSpecials.snofs?.strengthModel ?? clampImageV2Strength(fields.snofs_model, generateSnofsFallback)) : 0 const snofsClip = conceptAllowed ? (stackSpecials.snofs?.strengthClip ?? clampImageV2Strength(fields.snofs_clip, generateSnofsClipFallback)) : 0 const consistencyModel = stackSpecials.consistency?.strengthModel ?? generateConsistencyModel const consistencyClip = stackSpecials.consistency?.strengthClip ?? generateConsistencyClip let inputFilename = imageA?.filename || '' const postedStillId = String(fields.stillId || '').trim() if (postedStillId) { try { const existing = getStill(ownerKey, postedStillId) inputFilename = existing.name || existing.filename || inputFilename } catch { /* use the uploaded filename */ } } const clipName = allocateOutputName(ownerKey, folderId, String(fields.name || '').trim(), { inputFilename, engine, loraStack }) const still = imageA ? await rememberInputStill({ ownerKey, folderId, filename: imageA.filename, data: imageA.data, width: size?.width || 0, height: size?.height || 0, hideInput }) : null const savedRef = imageB ? await rememberInputStill({ ownerKey, folderId, filename: imageB.filename, data: imageB.data, hideInput }) : null const savedMask = mask ? await rememberInputStill({ ownerKey, folderId, filename: mask.filename || 'refine-mask.png', data: mask.data, hideInput }) : null const studio = await addStudioJob({ ownerKey, familyId: crypto.randomUUID(), kind: 'edit', payload: { prompt, promptPre: promptPre || undefined, promptPost: promptPost || undefined, name: clipName, folderId, aspect: mode === 'generate' ? generateSize.aspect : aspect, width: size?.width || 0, height: size?.height || 0, steps, turbo, seed, cfg, fps: 24, samplerName: 'euler', scheduler: 'simple', duration: 0, sound: false, workflow: 'v1', useIdentityRefs: false, stillId: still?.id, stillFilename: still?.filename, hideThumbnail, hideInput, folderLocked, referenceStillIds: [null, null, null, null], extensions: [], queueAutoRun: false, negative: String(fields.negative || '').trim(), passes: extraPasses, passMode, referenceStillId: savedRef?.id, referenceStillFilename: savedRef?.filename, scaleToTotalPixels: mode !== 'generate' && megapixels > 0, scaleMegapixels: megapixels, imagePipeline: 'v2', v2Mode, v2Task, engine, snofsModel, snofsClip, consistencyModel, consistencyClip, maskStillId: savedMask?.id, maskStillFilename: savedMask?.filename, refineStrength, ...persistLoraFields(loraStack) } }) await kickStudioQueue() let latest = listStudioJobs(ownerKey).find(item => item.id === studio.id) if (!latest?.liveJobId && !(await videoJobsBusy())) { await kickStudioQueue() latest = listStudioJobs(ownerKey).find(item => item.id === studio.id) } const liveJobId = latest?.liveJobId || '' return { jobId: liveJobId || studio.id, studioJobId: studio.id, queued: !liveJobId, seed, steps, cfg, mode: v2Mode, task: v2Task, engine, workflow: imageV2WorkflowFile(v2Mode, engine), width: size?.width || 0, height: size?.height || 0, strength: refineStrength, hideThumbnail, folderLocked, chainTotal: 1 + extraPasses.length } })