Files
aigen/server/api/v2/generate.post.ts
T

348 lines
12 KiB
TypeScript

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 } 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,
imageV2WorkflowFile,
parseImageV2Engine,
parseImageV2Mode,
parseImageV2Task,
type ImageV2Mode,
type ImageV2Task
} from '~/utils/imageV2'
import { isXaigenStudio } from '~/utils/videoModels'
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 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<ImageFile> {
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<string, string> = {}
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<string, unknown> = {}
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<Record<string, unknown>>(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()
const aspect = parseImageAspect(fields.aspect)
if (!prompt && (mode === 'generate' || aspect === 'auto')) {
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')
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 : 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 = 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)
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,
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(),
referenceStillId: savedRef?.id,
referenceStillFilename: savedRef?.filename,
scaleToTotalPixels: mode !== 'generate',
scaleMegapixels: megapixels,
imagePipeline: 'v2',
v2Mode,
v2Task,
engine,
snofsModel: conceptAllowed ? clampImageV2Strength(fields.snofs_model, generateSnofsFallback) : 0,
snofsClip: conceptAllowed ? clampImageV2Strength(fields.snofs_clip, generateSnofsClipFallback) : 0,
consistencyModel: generateConsistencyModel,
consistencyClip: generateConsistencyClip,
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
}
})