Keep uploaded stills in the input picker and generated work in the library, and run image-edit passes as a chain like video extensions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 09:58:08 -05:00
co-authored by Cursor
parent f88dfa1146
commit 860aeafb1e
9 changed files with 1001 additions and 177 deletions
+198 -106
View File
@@ -16,6 +16,19 @@ function parseCfg(raw: unknown) {
return Math.min(8, Math.max(0.5, Math.round(value * 10) / 10))
}
function parsePasses(raw: string | undefined) {
if (!raw) return [] as { prompt: string }[]
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed
.map((item: { prompt?: unknown }) => ({ prompt: String(item?.prompt || '').trim() }))
.filter(item => item.prompt)
} catch {
return []
}
}
export default defineEventHandler(async (event) => {
const form = await readMultipartFormData(event).catch(() => null)
const fields: Record<string, string> = {}
@@ -32,12 +45,16 @@ export default defineEventHandler(async (event) => {
}
const prompt = (fields.prompt || '').trim()
const extraPasses = parsePasses(fields.passes)
if (!image) {
throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
}
if (!prompt) {
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
}
if (extraPasses.some(item => !item.prompt)) {
throw createError({ statusCode: 400, statusMessage: 'Each extra pass needs a prompt' })
}
if (!comfyConfigured()) {
throw createError({
statusCode: 503,
@@ -65,19 +82,17 @@ export default defineEventHandler(async (event) => {
: Math.floor(Math.random() * 2_147_483_647)
const size = imageDimensions(image.data)
const still = isPipelineFrameFilename(image.filename)
? null
: await saveStill({
ownerKey,
folderId,
filename: image.filename,
data: image.data,
width: size?.width || 0,
height: size?.height || 0,
hideInput
})
if (reference && !isPipelineFrameFilename(reference.filename)) {
await saveStill({
const still = await rememberInputStill({
ownerKey,
folderId,
filename: image.filename,
data: image.data,
width: size?.width || 0,
height: size?.height || 0,
hideInput
})
if (reference) {
await rememberInputStill({
ownerKey,
folderId,
filename: reference.filename,
@@ -86,6 +101,9 @@ export default defineEventHandler(async (event) => {
})
}
const passes = extraPasses
const chainTotal = 1 + passes.length
const familyId = chainTotal > 1 ? crypto.randomUUID() : undefined
const job = createJob()
job.kind = 'edit'
job.maxStep = steps
@@ -105,14 +123,28 @@ export default defineEventHandler(async (event) => {
turbo: true,
seed,
cfg,
stillId: still?.id
stillId: still?.id,
familyId,
chainIndex: 0,
chainStep: 1,
chainTotal,
chainLabel: chainTotal > 1 ? 'Pass 1' : undefined,
passes
}
emitJob(job, { type: 'status', message: 'Checking Beast ComfyUI...', progress: 2 })
emitJob(job, {
type: 'status',
message: 'Checking Beast ComfyUI...',
progress: 2,
chainStep: 1,
chainTotal,
chainLabel: job.library.chainLabel
})
void runEdit(job, {
image,
reference,
prompt,
passes,
negative: (fields.negative || '').trim(),
steps,
seed,
@@ -131,7 +163,8 @@ export default defineEventHandler(async (event) => {
steps,
hideThumbnail: hideInput,
folderLocked,
dual: Boolean(reference)
dual: Boolean(reference),
chainTotal
}
})
@@ -141,14 +174,22 @@ async function runEdit(
image: { filename: string; data: Buffer; type?: string }
reference: { filename: string; data: Buffer; type?: string } | null
prompt: string
passes: { prompt: string }[]
negative: string
steps: number
seed: number
cfg: number
}
) {
const library = job.library
if (!library) throw new Error('Edit job is missing library metadata')
const prompts = [params.prompt, ...params.passes.map(item => item.prompt)]
const chainTotal = prompts.length
library.chainTotal = chainTotal
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
await ensureComfyReady((status) => {
emitJob(job, {
emitChainJob(job, {
type: status.state === 'busy' ? 'busy' : 'status',
message: status.message,
progress: status.state === 'online' ? Math.max(job.progress, 6) : Math.max(job.progress, 3),
@@ -159,96 +200,147 @@ async function runEdit(
job.imageComfyHost = getComfyHost()
await withImageComfyHost(job.imageComfyHost, async () => {
const dual = Boolean(params.reference)
emitJob(job, {
type: 'status',
message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...',
progress: 8
})
const uploaded = await uploadImage(params.image, job.id)
const uploadedRef = params.reference
? await uploadImage({
...params.reference,
filename: `ref_${params.reference.filename || 'image2.png'}`
}, job.id)
: null
if (job.status === 'cancelled') throw new Error('Job interrupted.')
emitJob(job, {
type: 'status',
message: dual
? 'Queueing Flux.2 Klein two-image edit on Beast...'
: 'Queueing Flux.2 Klein edit on Beast...',
progress: 12
})
const graph = buildEditWorkflow({
prompt: params.prompt,
negative: params.negative,
imageName: uploaded.name,
referenceImageName: uploadedRef?.name,
steps: params.steps,
seed: params.seed,
cfg: params.cfg,
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}`
})
const queued = await queuePrompt(graph, job.clientId)
job.promptId = queued.prompt_id
job.status = 'running'
emitJob(job, {
type: 'status',
message: dual ? 'Applying image 2 onto image 1...' : 'Editing still on Beast...',
progress: 18,
maxStep: params.steps
})
const output = await waitForImageEdit({
promptId: queued.prompt_id,
clientId: job.clientId,
timeoutMs: 10 * 60 * 1000,
onProgress: (event) => {
emitJob(job, {
type: 'status',
message: event.message,
progress: event.progress,
step: event.step,
maxStep: event.maxStep || params.steps,
node: event.node
let current = params.image
let parentStillId: string | undefined
for (let index = 0; index < prompts.length; index++) {
if (job.status === 'cancelled') throw new Error('Job interrupted.')
const prompt = prompts[index]
const last = index === prompts.length - 1
if (index > 0) {
await ensureComfyReady((status) => {
emitChainJob(job, {
type: status.state === 'busy' ? 'busy' : 'status',
message: status.message,
progress: status.state === 'online' ? 4 : 2,
busy: status.state === 'busy'
})
})
},
isCancelled: () => job.status === 'cancelled'
})
}
const seed = index === 0 ? params.seed : Math.floor(Math.random() * 2_147_483_647)
library.prompt = prompt
library.seed = seed
library.chainIndex = index
library.chainStep = index + 1
library.chainLabel = chainTotal > 1 ? `Pass ${index + 1}` : undefined
const reference = index === 0 ? params.reference : null
const dual = Boolean(reference)
const passName = stillChainName(library.name || '', index)
emitJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
const buffer = await downloadEditedImage(output)
const size = imageDimensions(buffer)
const still = await saveStill({
ownerKey: job.library!.ownerKey,
folderId: job.library!.folderId,
filename: job.library?.name ? `${job.library.name}.png` : output.filename,
data: buffer,
width: size?.width || 0,
height: size?.height || 0,
hideInput: job.library?.hideInput
})
job.stillId = still?.id
await purgeComfyArtifacts({
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
imageName: uploaded.name,
imageSubfolder: uploaded.subfolder,
extraImageNames: uploadedRef?.name ? [uploadedRef.name] : [],
promptId: job.promptId
})
job.status = 'complete'
emitJob(job, {
type: 'complete',
message: 'Edit finished on Beast',
progress: 100,
stillId: still?.id,
filename: output.filename,
subfolder: output.subfolder,
mediaType: 'image',
hideThumbnail: job.hideThumbnail,
folderLocked: job.library?.folderLocked
})
emitChainJob(job, {
type: 'status',
message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...',
progress: 8
})
const uploaded = await uploadImage(current, job.id)
const uploadedRef = reference
? await uploadImage({
...reference,
filename: `ref_${reference.filename || 'image2.png'}`
}, job.id)
: null
if (job.status === 'cancelled') throw new Error('Job interrupted.')
emitChainJob(job, {
type: 'status',
message: dual
? 'Queueing Flux.2 Klein two-image edit on Beast...'
: 'Queueing Flux.2 Klein edit on Beast...',
progress: 12
})
const graph = buildEditWorkflow({
prompt,
negative: params.negative,
imageName: uploaded.name,
referenceImageName: uploadedRef?.name,
steps: params.steps,
seed,
cfg: params.cfg,
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}_p${index + 1}`
})
const queued = await queuePrompt(graph, job.clientId)
job.promptId = queued.prompt_id
job.status = 'running'
emitChainJob(job, {
type: 'status',
message: dual ? 'Applying image 2 onto image 1...' : 'Editing still on Beast...',
progress: 18,
maxStep: params.steps
})
const output = await waitForImageEdit({
promptId: queued.prompt_id,
clientId: job.clientId,
timeoutMs: 10 * 60 * 1000,
onProgress: (event) => {
emitChainJob(job, {
type: 'status',
message: event.message,
progress: event.progress,
step: event.step,
maxStep: event.maxStep || params.steps,
node: event.node
})
},
isCancelled: () => job.status === 'cancelled'
})
emitChainJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
const buffer = await downloadEditedImage(output)
const size = imageDimensions(buffer)
const still = await saveStill({
ownerKey: library.ownerKey,
folderId: library.folderId,
filename: passName ? `${passName}.png` : output.filename,
data: buffer,
width: size?.width || 0,
height: size?.height || 0,
hideInput: job.hideThumbnail === true || library.hideInput === true,
role: 'output',
name: passName || undefined,
prompt,
familyId: library.familyId,
parentStillId,
chainIndex: index
})
job.stillId = still?.id
parentStillId = still?.id
await purgeComfyArtifacts({
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
imageName: uploaded.name,
imageSubfolder: uploaded.subfolder,
extraImageNames: uploadedRef?.name ? [uploadedRef.name] : [],
promptId: job.promptId
})
if (!last) {
emitChainJob(job, {
type: 'checkpoint',
message: `Pass ${index + 1} saved`,
progress: 100,
stillId: still?.id,
hideThumbnail: job.hideThumbnail,
folderLocked: library.folderLocked
})
current = {
filename: still?.filename || `pass_${index + 1}.png`,
data: buffer,
type: 'image/png'
}
continue
}
job.status = 'complete'
emitChainJob(job, {
type: 'complete',
message: chainTotal > 1 ? 'Edit chain finished on Beast' : 'Edit finished on Beast',
progress: 100,
stillId: still?.id,
filename: output.filename,
subfolder: output.subfolder,
mediaType: 'image',
hideThumbnail: job.hideThumbnail,
folderLocked: library.folderLocked
})
}
})
}
+10 -12
View File
@@ -123,22 +123,20 @@ export default defineEventHandler(async (event) => {
const destFolder = library.folders.find(folder => folder.id === folderId)
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
assertFolderExists(event, folderId)
const still = isPipelineFrameFilename(image.filename)
? null
: await saveStill({
ownerKey,
folderId,
filename: image.filename,
data: image.data,
width,
height,
hideInput
})
const still = await rememberInputStill({
ownerKey,
folderId,
filename: image.filename,
data: image.data,
width,
height,
hideInput
})
const referenceStillIds: Array<string | null> = [null, null, null, null]
if (useIdentityRefs) {
for (const [index, ref] of referenceImages.entries()) {
if (!ref?.data?.length || isPipelineFrameFilename(ref.filename)) continue
const saved = await saveStill({
const saved = await rememberInputStill({
ownerKey,
folderId,
filename: ref.filename || `identity-ref-${index + 2}.png`,
+2 -1
View File
@@ -31,7 +31,8 @@ export default defineEventHandler(async (event) => {
data: image.data,
width: Number(fields.width || 0),
height: Number(fields.height || 0),
hideInput: fields.hideInput === 'true'
hideInput: fields.hideInput === 'true',
role: 'input'
})
return { still: still || undefined, ...publicLibrary(event) }
})
+3 -1
View File
@@ -3,6 +3,8 @@ export default defineEventHandler(async (event) => {
const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id)
assertFolderAccess(event, still.folderId)
await deleteStill(owner, id)
const family = ['1', 'true'].includes(String(getQuery(event).family || '').toLowerCase())
if (family) await deleteStillFamily(owner, id)
else await deleteStill(owner, id)
return publicLibrary(event)
})
+23
View File
@@ -0,0 +1,23 @@
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ folderId?: string; name?: string; family?: boolean }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean }))
const folderId = String(body?.folderId || '')
const hasName = typeof body?.name === 'string'
const family = body?.family === true
if (!folderId && !hasName) {
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
}
const still = getStill(owner, id)
assertFolderAccess(event, still.folderId)
if (folderId) {
assertFolderExists(event, folderId)
if (family) await moveStillFamily(owner, id, folderId)
else await moveStill(owner, id, folderId)
}
if (hasName) {
if (family) await renameStillFamily(owner, id, body.name || '')
else await renameStill(owner, id, body.name || '')
}
return publicLibrary(event)
})