import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue' import { comfyConfigured } from '~/server/utils/comfy' import { imageDimensions } from '~/server/utils/resolution' import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps } from '~/utils/generationPresets' import { IMAGE_V2_CFG_DEFAULT, IMAGE_V2_CONSISTENCY_CLIP, IMAGE_V2_CONSISTENCY_MODEL, IMAGE_V2_SNOFS_CLIP, IMAGE_V2_SNOFS_MODEL, IMAGE_V2_STEPS_DEFAULT, IMAGE_V2_TURBO_CFG, IMAGE_V2_TURBO_STEPS, IMAGE_V2_KREA_CFG, IMAGE_V2_KREA_STEPS, IMAGE_V2_DENOISE_DEFAULT, clampImageV2Denoise, clampImageV2Size, clampImageV2Strength, imageV2WorkflowFile, parseImageV2Engine, parseImageV2Mode, parseImageV2Task, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2' import { getStill, rememberInputStill, stillPath } from '~/server/utils/library' import { existsSync, readFileSync } from 'node:fs' type ImageFile = { filename: string; data: Buffer; type?: string } 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.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 prompt = String(fields.prompt || '').trim() if (!prompt) { throw createError({ statusCode: 400, statusMessage: 'A prompt is required' }) } 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') if (requestedEngine === 'krea' && mode !== 'generate') { throw createError({ statusCode: 400, statusMessage: 'Krea is available on Generate first.' }) } const engine = mode === 'generate' ? requestedEngine : 'flux' 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 : clampImageScaleMegapixels(fields.megapixels ?? fields.scaleMegapixels, 1) const seed = fields.seed && String(fields.seed) !== 'random' ? Number(fields.seed) : Math.floor(Math.random() * 2_147_483_647) const generateSize = clampImageV2Size(fields.width, fields.height, fields.aspect) const size = mode === 'generate' ? { width: generateSize.width, height: generateSize.height } : imageDimensions(imageA!.data) const clipName = String(fields.name || '').trim().slice(0, 80) const v2Mode = mode as ImageV2Mode const v2Task = ( mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : mode === 'edit' ? 'scene' : task ) as ImageV2Task const refineStrength = mode === 'refine' ? clampImageV2Denoise(fields.strength, 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 generateSnofsFallback = engine === 'krea' ? 0 : IMAGE_V2_SNOFS_MODEL const generateSnofsClipFallback = engine === 'krea' ? 0 : IMAGE_V2_SNOFS_CLIP 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, name: clipName, folderId, aspect: mode === 'generate' ? (generateSize.aspect || '1:1') : 'auto', 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(), referenceStillId: savedRef?.id, referenceStillFilename: savedRef?.filename, scaleToTotalPixels: mode !== 'generate', scaleMegapixels: megapixels, imagePipeline: 'v2', v2Mode, v2Task, engine, snofsModel: clampImageV2Strength(fields.snofs_model, generateSnofsFallback), snofsClip: clampImageV2Strength(fields.snofs_clip, generateSnofsClipFallback), consistencyModel: generateConsistencyModel, consistencyClip: generateConsistencyClip, maskStillId: savedMask?.id, maskStillFilename: savedMask?.filename, refineStrength } }) 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 } })