Mask painter and denoise strength sit beside Edit and Compose. The canned hand/chest prompt helper is gone so that text stays yours. Co-authored-by: Cursor <cursoragent@cursor.com>
225 lines
8.1 KiB
TypeScript
225 lines
8.1 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { ownerRoot } from '~/server/utils/library'
|
|
import { filterLoraStackForStudio, normalizeLoraStack } from '~/utils/loras'
|
|
import {
|
|
clampImageCfg,
|
|
clampImageScaleMegapixels,
|
|
clampImageSteps,
|
|
clampVideoCfg,
|
|
normalizePresetName,
|
|
parseGenerationPresetKind,
|
|
type GenerationPreset,
|
|
type GenerationPresetKind,
|
|
type ImagePresetSettings,
|
|
type VideoPresetSettings
|
|
} 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_DENOISE_DEFAULT,
|
|
clampImageV2Denoise,
|
|
clampImageV2Strength,
|
|
parseImageV2Mode,
|
|
parseImageV2Task,
|
|
type ImageV2PresetSettings
|
|
} from '~/utils/imageV2'
|
|
import { coerceVideoWorkflow, isXaigenStudio, parseVideoWorkflow } from '~/utils/videoModels'
|
|
|
|
const writeChains = new Map<string, Promise<unknown>>()
|
|
|
|
function presetsPath(owner: string) {
|
|
return join(ownerRoot(owner), 'generation-presets.json')
|
|
}
|
|
|
|
function ensureOwner(owner: string) {
|
|
mkdirSync(ownerRoot(owner), { recursive: true })
|
|
}
|
|
|
|
function readPresets(owner: string): GenerationPreset[] {
|
|
ensureOwner(owner)
|
|
const path = presetsPath(owner)
|
|
if (!existsSync(path)) return []
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
return Array.isArray(parsed) ? parsed.map(normalizeStoredPreset).filter((item): item is GenerationPreset => Boolean(item)) : []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function writePresets(owner: string, presets: GenerationPreset[]) {
|
|
ensureOwner(owner)
|
|
const path = presetsPath(owner)
|
|
const tmp = `${path}.tmp`
|
|
writeFileSync(tmp, JSON.stringify(presets, null, 2))
|
|
renameSync(tmp, path)
|
|
}
|
|
|
|
function mutate<T>(owner: string, fn: (presets: GenerationPreset[]) => T): Promise<T> {
|
|
const prev = writeChains.get(owner) || Promise.resolve()
|
|
const run = prev.then(() => {
|
|
const presets = readPresets(owner)
|
|
const result = fn(presets)
|
|
writePresets(owner, presets)
|
|
return result
|
|
})
|
|
writeChains.set(owner, run.then(() => undefined, () => undefined))
|
|
return run
|
|
}
|
|
|
|
function clampNum(raw: unknown, fallback: number, min: number, max: number, step = 0.1) {
|
|
const value = Number(raw)
|
|
if (!Number.isFinite(value)) return fallback
|
|
const snapped = Math.round(value / step) * step
|
|
return Math.min(max, Math.max(min, Math.round(snapped * 100) / 100))
|
|
}
|
|
|
|
function sanitizeVideoSettings(raw: unknown): VideoPresetSettings {
|
|
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
|
|
const workflow = coerceVideoWorkflow(parseVideoWorkflow(rec.workflow))
|
|
const turbo = rec.turbo !== false
|
|
const sampler = String(rec.samplerName || '')
|
|
const scheduler = String(rec.scheduler || '')
|
|
const aspect = String(rec.aspect || 'auto')
|
|
return {
|
|
workflow,
|
|
turbo,
|
|
cfg: clampVideoCfg(rec.cfg, turbo ? 1.5 : 4),
|
|
fps: rec.fps === 12 || rec.fps === 30 || rec.fps === 24 ? Number(rec.fps) : 24,
|
|
samplerName: sampler === 'euler' || sampler === 'dpmpp_2m' || sampler === 'res_multistep' ? sampler : 'res_multistep',
|
|
scheduler: scheduler === 'ddim_uniform' || scheduler === 'sgm_uniform' || scheduler === 'simple' ? scheduler : 'simple',
|
|
duration: clampNum(rec.duration, 5, 0.5, 120, 0.1),
|
|
aspect: aspect === '16:9' || aspect === '9:16' || aspect === '1:1' || aspect === 'auto' ? aspect : 'auto',
|
|
sound: rec.sound !== false
|
|
}
|
|
}
|
|
|
|
function sanitizeImageSettings(raw: unknown): ImagePresetSettings {
|
|
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
|
|
return {
|
|
steps: clampImageSteps(rec.steps),
|
|
cfg: clampImageCfg(rec.cfg),
|
|
negative: String(rec.negative || '').slice(0, 2000),
|
|
scaleToTotalPixels: rec.scaleToTotalPixels === true,
|
|
scaleMegapixels: clampImageScaleMegapixels(rec.scaleMegapixels)
|
|
}
|
|
}
|
|
|
|
function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
|
|
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
|
|
const mode = parseImageV2Mode(rec.mode) || 'edit'
|
|
const turbo = rec.turbo === true
|
|
return {
|
|
mode,
|
|
task: mode === 'compose' ? parseImageV2Task(rec.task, 'scene') : mode === 'refine' ? 'refine' : 'scene',
|
|
negative: String(rec.negative || '').slice(0, 2000),
|
|
snofsModel: clampImageV2Strength(rec.snofsModel ?? rec.snofs_model, IMAGE_V2_SNOFS_MODEL),
|
|
snofsClip: clampImageV2Strength(rec.snofsClip ?? rec.snofs_clip, IMAGE_V2_SNOFS_CLIP),
|
|
consistencyModel: clampImageV2Strength(rec.consistencyModel ?? rec.consistency_model, IMAGE_V2_CONSISTENCY_MODEL),
|
|
consistencyClip: clampImageV2Strength(rec.consistencyClip ?? rec.consistency_clip, IMAGE_V2_CONSISTENCY_CLIP),
|
|
steps: turbo ? 8 : clampImageSteps(rec.steps, IMAGE_V2_STEPS_DEFAULT),
|
|
cfg: turbo ? 1 : clampImageCfg(rec.cfg, IMAGE_V2_CFG_DEFAULT),
|
|
megapixels: clampImageScaleMegapixels(rec.megapixels ?? rec.scaleMegapixels, 1),
|
|
turbo,
|
|
strength: mode === 'refine' ? clampImageV2Denoise(rec.strength, IMAGE_V2_DENOISE_DEFAULT) : undefined
|
|
}
|
|
}
|
|
|
|
function sanitizeSettings(kind: GenerationPresetKind, raw: unknown) {
|
|
if (kind === 'image') return sanitizeImageSettings(raw)
|
|
if (kind === 'imagev2') return sanitizeImageV2Settings(raw)
|
|
return sanitizeVideoSettings(raw)
|
|
}
|
|
|
|
function normalizeStoredPreset(raw: unknown): GenerationPreset | null {
|
|
if (!raw || typeof raw !== 'object') return null
|
|
const rec = raw as Record<string, unknown>
|
|
const name = normalizePresetName(rec.name)
|
|
if (!name) return null
|
|
const kind = parseGenerationPresetKind(rec.kind)
|
|
const id = String(rec.id || '').trim() || crypto.randomUUID()
|
|
const createdAt = Number(rec.createdAt) || Date.now()
|
|
return {
|
|
id,
|
|
name,
|
|
kind,
|
|
createdAt,
|
|
updatedAt: Number(rec.updatedAt) || createdAt,
|
|
loraStack: normalizeLoraStack(rec.loraStack),
|
|
settings: sanitizeSettings(kind, rec.settings)
|
|
}
|
|
}
|
|
|
|
function publicPreset(preset: GenerationPreset): GenerationPreset {
|
|
const xaigen = isXaigenStudio()
|
|
return {
|
|
...preset,
|
|
loraStack: filterLoraStackForStudio(preset.loraStack, xaigen),
|
|
settings: preset.kind === 'video'
|
|
? { ...preset.settings, workflow: coerceVideoWorkflow((preset.settings as VideoPresetSettings).workflow) }
|
|
: preset.settings
|
|
}
|
|
}
|
|
|
|
export function listGenerationPresets(owner: string, kind?: GenerationPresetKind) {
|
|
return readPresets(owner)
|
|
.filter(item => !kind || item.kind === kind)
|
|
.map(publicPreset)
|
|
.sort((a, b) => a.name.localeCompare(b.name) || b.updatedAt - a.updatedAt)
|
|
}
|
|
|
|
export async function saveGenerationPreset(owner: string, input: {
|
|
name: string
|
|
kind: GenerationPresetKind
|
|
loraStack?: unknown
|
|
settings?: unknown
|
|
overwrite?: boolean
|
|
}) {
|
|
const name = normalizePresetName(input.name)
|
|
if (!name) {
|
|
throw createError({ statusCode: 400, statusMessage: 'A preset name is required' })
|
|
}
|
|
const kind = parseGenerationPresetKind(input.kind)
|
|
const xaigen = isXaigenStudio()
|
|
const loraStack = filterLoraStackForStudio(input.loraStack, xaigen)
|
|
const settings = sanitizeSettings(kind, input.settings)
|
|
const now = Date.now()
|
|
return mutate(owner, (presets) => {
|
|
const existing = presets.find(item => item.kind === kind && item.name.toLowerCase() === name.toLowerCase())
|
|
if (existing && !input.overwrite) {
|
|
throw createError({ statusCode: 409, statusMessage: `Overwrite preset “${existing.name}”?` })
|
|
}
|
|
if (existing) {
|
|
existing.updatedAt = now
|
|
existing.loraStack = loraStack
|
|
existing.settings = settings
|
|
return publicPreset(existing)
|
|
}
|
|
const created: GenerationPreset = {
|
|
id: crypto.randomUUID(),
|
|
name,
|
|
kind,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
loraStack,
|
|
settings
|
|
}
|
|
presets.unshift(created)
|
|
return publicPreset(created)
|
|
})
|
|
}
|
|
|
|
export async function deleteGenerationPreset(owner: string, id: string) {
|
|
return mutate(owner, (presets) => {
|
|
const index = presets.findIndex(item => item.id === id)
|
|
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Preset not found' })
|
|
const [removed] = presets.splice(index, 1)
|
|
return removed
|
|
})
|
|
}
|