Name outputs from the input, engine, and LoRA keys instead of aigen_0001. Image-edit presets now store prompts, and aspect fill pads stills without stretching.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-30 16:24:49 -05:00
co-authored by Cursor
parent 4d411f83d4
commit 0e1222d035
23 changed files with 668 additions and 93 deletions
+17 -3
View File
@@ -2,6 +2,7 @@ import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/
import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/server/utils/loras'
import { assertImageScaleToTotalPixelsNode, comfyConfigured } from '~/server/utils/comfy'
import { imageDimensions } from '~/server/utils/resolution'
import { parseImageAspect } from '~/utils/imageAspect'
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImageScaleToTotalPixels } from '~/utils/generationPresets'
function parsePasses(raw: string | undefined) {
@@ -34,10 +35,11 @@ export default defineEventHandler(async (event) => {
const prompt = (fields.prompt || '').trim()
const extraPasses = parsePasses(fields.passes)
const aspect = parseImageAspect(fields.aspect)
if (!image) {
throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
}
if (!prompt) {
if (!prompt && aspect === 'auto') {
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
}
if (extraPasses.some(item => !item.prompt)) {
@@ -80,9 +82,21 @@ export default defineEventHandler(async (event) => {
? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647)
const size = imageDimensions(image.data)
const clipName = (fields.name || '').trim().slice(0, 80)
await listStudioLoras()
const loraStack = parsePostedLoraStack(fields.loraStack || fields.lora, 'image')
let inputFilename = image.filename
const postedStillId = (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, (fields.name || '').trim(), {
inputFilename,
engine: 'klein',
loraStack
})
const still = await rememberInputStill({
ownerKey,
@@ -114,7 +128,7 @@ export default defineEventHandler(async (event) => {
prompt,
name: clipName,
folderId,
aspect: 'auto',
aspect,
width: size?.width || 0,
height: size?.height || 0,
steps,
+9 -5
View File
@@ -3,7 +3,7 @@ import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/
import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
import { clampVideoCfg } from '~/utils/generationPresets'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow, videoEngineOf } from '~/utils/videoModels'
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
function parseDuration(raw: unknown) {
@@ -115,16 +115,16 @@ export default defineEventHandler(async (event) => {
const textToVideo = isTextToVideo(workflow)
const ownerKey = libraryOwnerKey(event)
const postedStillId = (fields.stillId || '').trim()
let libraryStill: { id: string; filename: string } | null = null
let libraryStill: { id: string; filename: string; name?: string } | null = null
if (postedStillId) {
const path = stillPath(ownerKey, postedStillId)
if (existsSync(path)) {
try {
const existing = getStill(ownerKey, postedStillId)
libraryStill = { id: existing.id, filename: existing.filename }
libraryStill = { id: existing.id, filename: existing.filename, name: existing.name }
if (!image) {
image = {
filename: existing.filename || 'still.png',
filename: existing.name ? `${existing.name}.png` : (existing.filename || 'still.png'),
data: readFileSync(path),
type: 'image/png'
}
@@ -179,7 +179,6 @@ export default defineEventHandler(async (event) => {
const hideThumbnail = fields.hideThumbnail === 'true'
const hideInput = fields.hideInput === 'true'
const sound = !isLtxWorkflow(workflow) && fields.sound !== 'false'
const clipName = (fields.name || '').trim().slice(0, 80)
const library = publicLibrary(event)
const folderId = library.folders.some(folder => folder.id === fields.folderId)
? fields.folderId
@@ -204,6 +203,11 @@ export default defineEventHandler(async (event) => {
if (!textToVideo && !still?.id) {
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
}
const clipName = allocateOutputName(ownerKey, folderId, (fields.name || '').trim(), {
inputFilename: libraryStill?.name || image?.filename || still?.filename || '',
engine: videoEngineOf(workflow),
loraStack: defaultStack
})
console.info('[aigen generate]', {
hasImage: Boolean(image?.data?.length),
imageBytes: image?.data?.length || 0,
+8
View File
@@ -0,0 +1,8 @@
import { libraryOwnerKey, publicLibrary, setLoraAlias } from '~/server/utils/library'
export default defineEventHandler(async (event) => {
const body = await readBody<{ key?: unknown; alias?: unknown }>(event)
const owner = libraryOwnerKey(event)
await setLoraAlias(owner, String(body?.key || ''), String(body?.alias || ''))
return publicLibrary(event)
})
+21 -5
View File
@@ -1,5 +1,6 @@
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 {
@@ -63,7 +64,7 @@ function fileFromStill(ownerKey: string, id: string): ImageFile {
throw createError({ statusCode: 400, statusMessage: 'That still is missing from the library' })
}
return {
filename: still.filename || `${still.id}.png`,
filename: still.name ? `${still.name}.png` : (still.filename || `${still.id}.png`),
data: readFileSync(path),
type: 'image/png'
}
@@ -123,7 +124,8 @@ export default defineEventHandler(async (event) => {
mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene'
)
const prompt = String(fields.prompt || '').trim()
if (!prompt) {
const aspect = parseImageAspect(fields.aspect)
if (!prompt && (mode === 'generate' || aspect === 'auto')) {
throw createError({ statusCode: 400, statusMessage: 'A prompt is required' })
}
if (!comfyConfigured()) {
@@ -193,11 +195,12 @@ export default defineEventHandler(async (event) => {
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 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 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
@@ -223,6 +226,19 @@ export default defineEventHandler(async (event) => {
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({
@@ -262,7 +278,7 @@ export default defineEventHandler(async (event) => {
prompt,
name: clipName,
folderId,
aspect: mode === 'generate' ? (generateSize.aspect || '1:1') : 'auto',
aspect: mode === 'generate' ? generateSize.aspect : aspect,
width: size?.width || 0,
height: size?.height || 0,
steps,
+2 -2
View File
@@ -1,4 +1,5 @@
import { LTX_NEGATIVE } from '~/utils/videoModels'
import { comfyJobPrefix } from '~/utils/outputNames'
let comfyHostOverride = ''
@@ -103,8 +104,7 @@ export function comfyInputFilename(original: string, jobId?: string) {
const dot = raw.lastIndexOf('.')
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png'
const base = (dot >= 0 ? raw.slice(0, dot) : raw).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48) || 'still'
const id = String(jobId || crypto.randomUUID()).replace(/-/g, '').slice(0, 10)
return `aigen_${id}_${base}${ext}`
return `${comfyJobPrefix(jobId)}_${base}${ext}`
}
function comfyQueueMessage(payload: unknown, status: number) {
+11 -2
View File
@@ -9,6 +9,7 @@ import {
clampVideoCfg,
normalizePresetName,
parseGenerationPresetKind,
PRESET_PROMPT_MAX,
type GenerationPreset,
type GenerationPresetKind,
type ImagePresetSettings,
@@ -105,14 +106,20 @@ function sanitizeVideoSettings(raw: unknown): VideoPresetSettings {
}
}
function sanitizePrompt(raw: unknown) {
return typeof raw === 'string' ? raw.slice(0, PRESET_PROMPT_MAX) : undefined
}
function sanitizeImageSettings(raw: unknown): ImagePresetSettings {
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
return {
prompt: sanitizePrompt(rec.prompt),
steps: clampImageSteps(rec.steps),
cfg: clampImageCfg(rec.cfg),
negative: String(rec.negative || '').slice(0, 2000),
scaleToTotalPixels: rec.scaleToTotalPixels === true,
scaleMegapixels: clampImageScaleMegapixels(rec.scaleMegapixels)
scaleMegapixels: clampImageScaleMegapixels(rec.scaleMegapixels),
aspect: rec.aspect === '16:9' || rec.aspect === '9:16' || rec.aspect === '1:1' || rec.aspect === 'auto' ? rec.aspect : 'auto'
}
}
@@ -125,6 +132,7 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
mode,
engine,
task: mode === 'compose' ? parseImageV2Task(rec.task, 'scene') : mode === 'refine' ? 'refine' : mode === 'generate' ? 't2i' : 'scene',
prompt: sanitizePrompt(rec.prompt),
negative: String(rec.negative || '').slice(0, 2000),
snofsModel: isXaigenStudio() ? clampImageV2Strength(rec.snofsModel ?? rec.snofs_model, IMAGE_V2_SNOFS_MODEL) : 0,
snofsClip: isXaigenStudio() ? clampImageV2Strength(rec.snofsClip ?? rec.snofs_clip, IMAGE_V2_SNOFS_CLIP) : 0,
@@ -141,7 +149,8 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
mode === 'compose' ? IMAGE_V2_KREA_COMPOSE_DENOISE : mode === 'edit' ? IMAGE_V2_KREA_EDIT_DENOISE : IMAGE_V2_DENOISE_DEFAULT
),
width: mode === 'generate' ? clampImageV2Size(rec.width, rec.height, rec.aspect).width : undefined,
height: mode === 'generate' ? clampImageV2Size(rec.width, rec.height, rec.aspect).height : undefined
height: mode === 'generate' ? clampImageV2Size(rec.width, rec.height, rec.aspect).height : undefined,
aspect: rec.aspect === '16:9' || rec.aspect === '9:16' || rec.aspect === '1:1' || rec.aspect === 'auto' ? String(rec.aspect) : (mode === 'generate' ? '1:1' : 'auto')
}
}
+16 -3
View File
@@ -7,6 +7,7 @@ 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'
export type EditImageFile = { filename: string; data: Buffer; type?: string }
@@ -19,6 +20,7 @@ export type EditRunParams = {
steps: number
seed: number
cfg: number
aspect?: string
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
scaleToTotalPixels?: boolean
@@ -46,12 +48,22 @@ export async function runEdit(job: Job, params: EditRunParams) {
job.imageComfyHost = getComfyHost()
await withImageComfyHost(job.imageComfyHost, async () => {
let current = params.image
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 prompt = prompts[index]
const prompt = index === 0 ? composeFillPrompt(prompts[index], filled.padded) : prompts[index]
const last = index === prompts.length - 1
if (index > 0) {
await ensureComfyReady((status) => {
@@ -104,7 +116,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
steps: params.steps,
seed,
cfg: params.cfg,
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}_p${index + 1}`,
filenamePrefix: `${comfyJobPrefix(job.id)}_p${index + 1}`,
loraStack: params.loraStack || params.loraName,
scaleToTotalPixels: params.scaleToTotalPixels,
scaleMegapixels: params.scaleMegapixels
@@ -161,6 +173,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
seed,
scaleToTotalPixels: params.scaleToTotalPixels === true,
scaleMegapixels: params.scaleMegapixels,
aspect: params.aspect || 'auto',
loraName: params.loraName,
loraStack: params.loraStack,
sourceStillId: library.stillId
+34 -7
View File
@@ -7,6 +7,7 @@ import { ensureComfyLoraNames, persistLoraFields } from '~/server/utils/loras'
import { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch'
import { saveStill } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames'
import { parseImageV2Engine, type ImageV2Engine, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2'
import type { EditImageFile } from '~/server/utils/imageChain'
@@ -31,6 +32,7 @@ export type EditV2RunParams = {
width?: number
height?: number
turbo?: boolean
aspect?: string
sourceStillId?: string
referenceStillId?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
@@ -77,7 +79,31 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
: params.mode === 'compose' ? 'Uploading stills A and B to Beast...' : 'Uploading still A to Beast...',
progress: generate ? 12 : 8
})
const uploaded = generate || !params.image ? null : await uploadImage(params.image, job.id)
const { applyImageFill } = await import('~/server/utils/imagePad')
const { composeFillPrompt } = await import('~/utils/imageAspect')
let image = params.image
let mask = params.mask
let prompt = params.prompt
let padded = false
if (!generate && image) {
const filled = await applyImageFill({
image,
mask: params.mode === 'refine' ? mask : null,
aspect: params.aspect || 'auto'
})
image = filled.image
if (filled.mask) mask = filled.mask
padded = filled.padded
prompt = composeFillPrompt(prompt, padded)
if (padded) {
emitChainJob(job, {
type: 'status',
message: `Filling to ${filled.aspect} without stretching...`,
progress: 7
})
}
}
const uploaded = generate || !image ? null : await uploadImage(image, job.id)
const uploadedRef = generate || params.mode === 'refine'
? null
: params.reference
@@ -86,10 +112,10 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
filename: `ref_${params.reference.filename || 'image_b.png'}`
}, job.id)
: null
const uploadedMask = !generate && params.mode === 'refine' && params.mask
const uploadedMask = !generate && params.mode === 'refine' && mask
? await uploadImage({
...params.mask,
filename: `mask_${params.mask.filename || 'refine-mask.png'}`
...mask,
filename: `mask_${mask.filename || 'refine-mask.png'}`
}, job.id)
: null
if (job.status === 'cancelled') throw new Error('Job interrupted.')
@@ -112,7 +138,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
mode: params.mode,
engine,
task: params.task,
prompt: params.prompt,
prompt,
negative: params.negative,
imageAName: uploaded?.name,
imageBName: uploadedRef?.name,
@@ -129,7 +155,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
width: params.width,
height: params.height,
turbo: params.turbo === true,
filenamePrefix: `aigen_v2_${job.id.slice(0, 8)}`,
filenamePrefix: comfyJobPrefix(job.id),
kreaUnetName: krea?.unet,
kreaClipName: krea?.clip,
kreaVaeName: krea?.vae,
@@ -174,7 +200,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
const still = await saveStill({
ownerKey: library.ownerKey,
folderId: library.folderId,
filename: output.filename,
filename: library.name ? `${library.name}.png` : output.filename,
data: buffer,
width: size?.width || 0,
height: size?.height || 0,
@@ -199,6 +225,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
strength: params.mode === 'generate' ? undefined : params.strength,
width: params.mode === 'generate' ? params.width : undefined,
height: params.mode === 'generate' ? params.height : undefined,
aspect: params.aspect || 'auto',
seed: params.seed,
sourceStillId: params.sourceStillId || library.stillId,
referenceStillId: params.referenceStillId,
+2 -2
View File
@@ -1,5 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { getComfyHost } from '~/server/utils/comfy'
import { comfyJobPrefix } from '~/utils/outputNames'
const imageHostAls = new AsyncLocalStorage<string>()
let imageComfyHostOverride = ''
@@ -98,8 +99,7 @@ function imageInputFilename(original: string, jobId?: string) {
const dot = raw.lastIndexOf('.')
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png'
const base = (dot >= 0 ? raw.slice(0, dot) : raw).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48) || 'still'
const id = String(jobId || crypto.randomUUID()).replace(/-/g, '').slice(0, 10)
return `aigen_edit_${id}_${base}${ext}`
return `${comfyJobPrefix(jobId)}_${base}${ext}`
}
export async function uploadImageEdit(file: { filename: string; data: Buffer; type?: string }, jobId?: string) {
+79
View File
@@ -0,0 +1,79 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { extname, join } from 'node:path'
import { runFfmpeg } from '~/server/utils/ffmpeg'
import { imageDimensions } from '~/server/utils/resolution'
import { padExtents, parseImageAspect, type ImageAspectId } from '~/utils/imageAspect'
export type PaddedImage = { filename: string; data: Buffer; type?: string }
function sourceExt(filename: string, type?: string) {
const fromName = extname(filename || '').toLowerCase()
if (fromName === '.jpg' || fromName === '.jpeg' || fromName === '.png' || fromName === '.webp') return fromName
if (type?.includes('jpeg')) return '.jpg'
if (type?.includes('webp')) return '.webp'
return '.png'
}
export async function applyImageFill(opts: {
image: PaddedImage
mask?: PaddedImage | null
aspect: string
}): Promise<{ image: PaddedImage; mask?: PaddedImage; padded: boolean; aspect: ImageAspectId }> {
const aspect = parseImageAspect(opts.aspect)
if (aspect === 'auto') {
return { image: opts.image, mask: opts.mask || undefined, padded: false, aspect }
}
const size = imageDimensions(opts.image.data)
if (!size?.width || !size.height) {
return { image: opts.image, mask: opts.mask || undefined, padded: false, aspect }
}
const box = padExtents(size.width, size.height, aspect)
if (!box) {
return { image: opts.image, mask: opts.mask || undefined, padded: false, aspect }
}
const dir = mkdtempSync(join(tmpdir(), 'aigen-pad-'))
try {
const srcPath = join(dir, `src${sourceExt(opts.image.filename, opts.image.type)}`)
const outPath = join(dir, 'out.png')
writeFileSync(srcPath, opts.image.data)
await runFfmpeg([
'-y',
'-i', srcPath,
'-vf', `pad=${box.canvasW}:${box.canvasH}:${box.x}:${box.y}:black`,
'-frames:v', '1',
outPath
])
const padded: PaddedImage = {
filename: 'padded.png',
data: readFileSync(outPath),
type: 'image/png'
}
if (!opts.mask?.data?.length) {
return { image: padded, padded: true, aspect }
}
const userPath = join(dir, `mask${sourceExt(opts.mask.filename, opts.mask.type)}`)
const maskPath = join(dir, 'pad-mask.png')
writeFileSync(userPath, opts.mask.data)
await runFfmpeg([
'-y',
'-f', 'lavfi',
'-i', `color=c=white:s=${box.canvasW}x${box.canvasH}:d=1:r=1`,
'-i', userPath,
'-filter_complex', `[1]scale=${box.srcW}:${box.srcH}[m];[0][m]overlay=${box.x}:${box.y}`,
'-frames:v', '1',
maskPath
])
return {
image: padded,
mask: { filename: 'pad-mask.png', data: readFileSync(maskPath), type: 'image/png' },
padded: true,
aspect
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function buildEditWorkflow(params: EditParams) {
const dual = Boolean(params.referenceImageName)
const prompt = String(params.prompt || '').trim()
const negative = String(params.negative || '')
const prefix = params.filenamePrefix || 'aigen-edit'
const prefix = params.filenamePrefix || 'edit'
setImage(graph, IMAGE1, params.imageName)
setText(graph, SINGLE_PROMPT, prompt)
+3 -3
View File
@@ -400,7 +400,7 @@ function buildKreaItiWorkflow(params: ImageV2BuildParams) {
setInput(graph, '15', 'sampler_name', 'euler')
setInput(graph, '15', 'scheduler', 'simple')
setInput(graph, '15', 'denoise', denoise)
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || `aigen-v2-krea-${params.mode}`)
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || `v2-krea-${params.mode}`)
patchScaleMegapixels(graph, params.megapixels ?? 1)
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'krea'), 'krea')
@@ -448,7 +448,7 @@ function buildKreaGenerateWorkflow(params: ImageV2BuildParams) {
setInput(graph, '15', 'cfg', cfg)
setInput(graph, '15', 'sampler_name', 'euler')
setInput(graph, '15', 'scheduler', 'simple')
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || 'aigen-v2-krea')
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || 'v2-krea')
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'krea'), 'krea')
assertImageV2Graph(graph, 'generate', undefined, 'krea')
@@ -512,7 +512,7 @@ export function buildImageV2Workflow(params: ImageV2BuildParams) {
setInput(graph, NOISE, 'noise_seed', params.seed)
setInput(graph, SCHEDULER, 'steps', steps)
setInput(graph, CFG, 'cfg', cfg)
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || (generate ? 'aigen-v2-generate' : 'aigen-v2'))
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || (generate ? 'v2-generate' : 'v2'))
if (!generate) patchScaleMegapixels(graph, params.megapixels ?? 1)
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'flux'), 'flux')
+54 -4
View File
@@ -8,6 +8,8 @@ import type { H3Event } from 'h3'
import { extractFirstFrame, extractFrameAt, extractLastFrame, probeDuration, remuxFaststart } from '~/server/utils/ffmpeg'
import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
import { normalizeStillSettings, type StillGenerationSettings } from '~/utils/libraryGeneration'
import { buildOutputBaseName, nextUniqueName, normalizeLoraAliases, sanitizeLoraAlias } from '~/utils/outputNames'
import { loraIdentityKey, type LoraStackItem } from '~/utils/loras'
const scryptAsync = promisify(scrypt)
@@ -128,6 +130,7 @@ interface Catalog {
clips: LibraryClip[]
stills: LibraryStill[]
drafts: RetryDraft[]
loraAliases: Record<string, string>
}
const writeChains = new Map<string, Promise<unknown>>()
@@ -166,7 +169,8 @@ function emptyCatalog(): Catalog {
}],
clips: [],
stills: [],
drafts: []
drafts: [],
loraAliases: {}
}
}
@@ -225,7 +229,8 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
folders,
clips: Array.isArray(parsed.clips) ? parsed.clips : [],
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [],
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : []
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [],
loraAliases: normalizeLoraAliases(parsed.loraAliases)
}
}
@@ -359,7 +364,51 @@ export function stillChainName(base: string, index: number) {
export function safeDownloadName(name: string) {
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
return cleaned.slice(0, 80) || 'aigen'
return cleaned.slice(0, 80) || 'output'
}
function existingOutputNames(catalog: Catalog, folderId: string) {
const stills = catalog.stills
.filter(item => item.folderId === folderId)
.map(item => String(item.name || '').trim())
.filter(Boolean)
const clips = catalog.clips
.filter(item => item.folderId === folderId)
.map(item => String(item.name || '').trim())
.filter(Boolean)
return [...stills, ...clips]
}
export function getLoraAliases(owner: string) {
return { ...(readCatalog(owner).loraAliases || {}) }
}
export async function setLoraAlias(owner: string, identity: string, alias: string) {
const key = loraIdentityKey(identity) || String(identity || '').trim().toLowerCase()
if (!key) throw createError({ statusCode: 400, statusMessage: 'A LoRA file is required' })
const cleaned = sanitizeLoraAlias(alias)
return mutate(owner, (catalog) => {
if (!catalog.loraAliases) catalog.loraAliases = {}
if (!cleaned) delete catalog.loraAliases[key]
else catalog.loraAliases[key] = cleaned
return { ...catalog.loraAliases }
})
}
export function allocateOutputName(owner: string, folderId: string, requested: string, opts: {
inputFilename?: string
engine: string
loraStack?: LoraStackItem[] | unknown
}) {
const catalog = readCatalog(owner)
const base = buildOutputBaseName({
requested,
inputFilename: opts.inputFilename,
engine: opts.engine,
loraStack: opts.loraStack,
aliases: catalog.loraAliases || {}
})
return nextUniqueName(existingOutputNames(catalog, folderId), base)
}
export function passwordVersion(hash: string | null) {
@@ -425,7 +474,8 @@ export function publicLibrary(event: H3Event) {
stills: catalog.stills
.filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still))
.map(still => ({ ...still, role: stillRole(still) })),
drafts: catalog.drafts
drafts: catalog.drafts,
loraAliases: catalog.loraAliases || {}
}
}
+2 -2
View File
@@ -2,6 +2,7 @@ import { getSidecarImageHost } from '~/server/utils/imageComfy'
import { ensureSidecarReady } from '~/server/utils/imageComfyLifecycle'
import { buildVisionPromptWorkflow } from '~/server/utils/promptWorkflow'
import { normalizeShotScript } from '~/utils/parseRecommend'
import { comfyJobPrefix } from '~/utils/outputNames'
export type PromptJobStatus = 'queued' | 'running' | 'complete' | 'error'
@@ -46,8 +47,7 @@ function promptInputFilename(original: string, jobId?: string) {
const dot = raw.lastIndexOf('.')
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png'
const base = (dot >= 0 ? raw.slice(0, dot) : raw).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48) || 'still'
const id = String(jobId || crypto.randomUUID()).replace(/-/g, '').slice(0, 10)
return `aigen_prompt_${id}_${base}${ext}`
return `${comfyJobPrefix(jobId)}_${base}${ext}`
}
async function uploadPromptImage(file: { filename: string; data: Buffer; type?: string }, jobId?: string) {
+2
View File
@@ -803,6 +803,7 @@ async function startStudioEditJob(item: StudioJob) {
consistencyClip: payload.consistencyClip ?? (mode === 'generate' || payload.engine === 'krea' ? 0 : 0.7),
megapixels: payload.scaleMegapixels ?? 1,
strength: payload.refineStrength,
aspect: payload.aspect || 'auto',
width: payload.width,
height: payload.height,
turbo: payload.turbo === true,
@@ -860,6 +861,7 @@ async function startStudioEditJob(item: StudioJob) {
steps: payload.steps,
seed: live.library?.seed || payload.seed,
cfg: payload.cfg,
aspect: payload.aspect || 'auto',
scaleToTotalPixels: payload.scaleToTotalPixels === true,
scaleMegapixels: payload.scaleMegapixels,
...persistLoraFields(payload.loraStack || payload.loraName)