Add same-mode extra passes to Image v2, and hide Shot script from image Recommend.

Library tiles show a check when selected, and xAIGen can hide a preview from the details pane.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-30 18:17:17 -05:00
co-authored by Cursor
parent fd9831dfcf
commit e89b47b5f9
8 changed files with 500 additions and 206 deletions
+4 -2
View File
@@ -1,13 +1,14 @@
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; globalLocks?: string; permanenceRefs?: unknown }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean; globalLocks?: string; permanenceRefs?: unknown }))
const body = await readBody<{ folderId?: string; name?: string; family?: boolean; globalLocks?: string; permanenceRefs?: unknown; hideThumbnail?: boolean }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean; globalLocks?: string; permanenceRefs?: unknown; hideThumbnail?: boolean }))
const folderId = String(body?.folderId || '')
const hasName = typeof body?.name === 'string'
const family = body?.family === true
const hasLocks = typeof body?.globalLocks === 'string'
const hasRefs = Array.isArray(body?.permanenceRefs)
if (!folderId && !hasName && !hasLocks && !hasRefs) {
const hasHide = typeof body?.hideThumbnail === 'boolean'
if (!folderId && !hasName && !hasLocks && !hasRefs && !hasHide) {
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
}
const clip = getClip(owner, id)
@@ -28,5 +29,6 @@ export default defineEventHandler(async (event) => {
family: family || hasLocks
})
}
if (hasHide) setClipHidden(owner, id, body.hideThumbnail === true)
return publicLibrary(event)
})
+4 -2
View File
@@ -1,11 +1,12 @@
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 body = await readBody<{ folderId?: string; name?: string; family?: boolean; hideInput?: boolean }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean; hideInput?: boolean }))
const folderId = String(body?.folderId || '')
const hasName = typeof body?.name === 'string'
const family = body?.family === true
if (!folderId && !hasName) {
const hasHide = typeof body?.hideInput === 'boolean'
if (!folderId && !hasName && !hasHide) {
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
}
const still = getStill(owner, id)
@@ -19,5 +20,6 @@ export default defineEventHandler(async (event) => {
if (family) await renameStillFamily(owner, id, body.name || '')
else await renameStill(owner, id, body.name || '')
}
if (hasHide) setStillHidden(owner, id, body.hideInput === true)
return publicLibrary(event)
})
+25 -1
View File
@@ -32,6 +32,18 @@ import { existsSync, readFileSync } from 'node:fs'
type ImageFile = { filename: string; data: Buffer; type?: string }
function parsePasses(raw: unknown) {
if (!raw) return [] as { prompt: string }[]
let parsed = raw
if (typeof raw === 'string') {
try { parsed = JSON.parse(raw) } catch { return [] }
}
if (!Array.isArray(parsed)) return []
return parsed
.map((item: { prompt?: unknown }) => ({ prompt: String(item?.prompt || '').trim() }))
.filter(item => item.prompt)
}
function parseBool(raw: unknown) {
return raw === true || raw === 'true' || raw === '1' || raw === 1
}
@@ -124,10 +136,20 @@ export default defineEventHandler(async (event) => {
mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene'
)
const prompt = String(fields.prompt || '').trim()
const extraPasses = parsePasses(fields.passes)
const aspect = parseImageAspect(fields.aspect)
if (!prompt && (mode === 'generate' || aspect === 'auto')) {
throw createError({ statusCode: 400, statusMessage: 'A prompt is required' })
}
if (extraPasses.length && (mode === 'refine' || mode === 'generate')) {
throw createError({
statusCode: 400,
statusMessage: 'Extra passes are only for Edit and Compose. Refine and Generate stay a single run.'
})
}
if (extraPasses.some(item => !item.prompt)) {
throw createError({ statusCode: 400, statusMessage: 'Each extra pass needs a prompt' })
}
if (!comfyConfigured()) {
throw createError({
statusCode: 503,
@@ -301,6 +323,7 @@ export default defineEventHandler(async (event) => {
extensions: [],
queueAutoRun: false,
negative: String(fields.negative || '').trim(),
passes: extraPasses,
referenceStillId: savedRef?.id,
referenceStillFilename: savedRef?.filename,
scaleToTotalPixels: mode !== 'generate',
@@ -342,6 +365,7 @@ export default defineEventHandler(async (event) => {
height: size?.height || 0,
strength: refineStrength,
hideThumbnail,
folderLocked
folderLocked,
chainTotal: 1 + extraPasses.length
}
})
+236 -160
View File
@@ -6,7 +6,7 @@ import { buildImageV2Workflow, IMAGE_V2_NODE_LABELS, resolveKreaGenerateAssets }
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 { saveStill, stillChainName } 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'
@@ -36,6 +36,7 @@ export type EditV2RunParams = {
sourceStillId?: string
referenceStillId?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
passes?: { prompt: string }[]
}
export async function runEditV2(job: Job, params: EditV2RunParams) {
@@ -54,6 +55,15 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
throw new Error('This v2 mode requires still A.')
}
const canChain = params.mode === 'edit' || params.mode === 'compose'
const extraPasses = canChain
? (params.passes || []).map(item => ({ prompt: String(item.prompt || '').trim() })).filter(item => item.prompt)
: []
const prompts = [params.prompt, ...extraPasses.map(item => item.prompt)]
const chainTotal = prompts.length
library.chainTotal = chainTotal
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
try {
await ensureComfyReady((status) => {
emitChainJob(job, {
@@ -70,189 +80,255 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
const generate = params.mode === 'generate'
const engine = parseImageV2Engine(params.engine, 'flux')
const engineLabel = engine === 'krea' ? 'Krea' : 'Flux'
emitChainJob(job, {
type: 'status',
message: generate
? `Queueing ${engineLabel} generate on Beast...`
: params.mode === 'refine'
? 'Uploading canvas and mask to Beast...'
: params.mode === 'compose' ? 'Uploading stills A and B to Beast...' : 'Uploading still A to Beast...',
progress: generate ? 12 : 8
})
const { applyImageFill } = await import('~/server/utils/imagePad')
const { composeFillPrompt } = await import('~/utils/imageAspect')
let image = params.image
let current = params.image || null
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
? await uploadImage({
...params.reference,
filename: `ref_${params.reference.filename || 'image_b.png'}`
}, job.id)
: null
const uploadedMask = !generate && params.mode === 'refine' && mask
? await uploadImage({
...mask,
filename: `mask_${mask.filename || 'refine-mask.png'}`
}, job.id)
: null
if (job.status === 'cancelled') throw new Error('Job interrupted.')
let parentStillId: string | undefined
let uploadedRef: { name: string } | null = null
let uploadedMask: { name: string } | null = null
if (!generate) {
emitChainJob(job, {
type: 'status',
message: params.mode === 'refine'
? `Queueing ${engineLabel} v2 refine on Beast...`
: params.mode === 'compose'
? `Queueing ${engineLabel} v2 compose (${params.task}) on Beast...`
: `Queueing ${engineLabel} v2 edit on Beast...`,
progress: 12
})
}
await ensureComfyLoraNames('image')
if (!generate) await assertImageScaleToTotalPixelsNode()
const krea = engine === 'krea' ? await resolveKreaGenerateAssets() : null
const built = buildImageV2Workflow({
mode: params.mode,
engine,
task: params.task,
prompt,
negative: params.negative,
imageAName: uploaded?.name,
imageBName: uploadedRef?.name,
maskName: uploadedMask?.name,
strength: params.strength,
snofsModel: params.snofsModel,
snofsClip: params.snofsClip,
consistencyModel: params.consistencyModel,
consistencyClip: params.consistencyClip,
steps: params.steps,
cfg: params.cfg,
seed: params.seed,
megapixels: params.megapixels,
width: params.width,
height: params.height,
turbo: params.turbo === true,
filenamePrefix: comfyJobPrefix(job.id),
kreaUnetName: krea?.unet,
kreaClipName: krea?.clip,
kreaVaeName: krea?.vae,
kreaConceptLora: krea?.conceptLora,
loraStack: params.loraStack
})
const queued = await queuePrompt(built.graph, job.clientId)
job.promptId = queued.prompt_id
job.status = 'running'
emitChainJob(job, {
type: 'status',
message: `Running ${built.workflowFile}...`,
progress: 18,
maxStep: params.steps
})
const output = await waitForImageEdit({
promptId: queued.prompt_id,
clientId: job.clientId,
timeoutMs: 10 * 60 * 1000,
engineLabel,
nodeLabel: (node) => {
if (node === '15' || node === '19') return `Sampling ${engineLabel}`
return IMAGE_V2_NODE_LABELS[node]
},
onProgress: (event) => {
if (!generate && params.mode === 'compose' && params.reference) {
uploadedRef = await uploadImage({
...params.reference,
filename: `ref_${params.reference.filename || 'image_b.png'}`
}, job.id)
}
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 last = index === prompts.length - 1
const seed = index === 0 ? params.seed : Math.floor(Math.random() * 2_147_483_647)
library.prompt = prompts[index]
library.seed = seed
library.chainIndex = index
library.chainStep = index + 1
library.chainLabel = chainTotal > 1 ? `Pass ${index + 1}` : undefined
const passName = stillChainName(library.name || '', index)
let prompt = prompts[index]
let image = current
let padded = false
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'
})
}, { skipBusyWait: true })
}
emitChainJob(job, {
type: 'status',
message: generate
? `Queueing ${engineLabel} generate on Beast...`
: params.mode === 'refine'
? 'Uploading canvas and mask to Beast...'
: params.mode === 'compose' ? 'Uploading stills A and B to Beast...' : 'Uploading still A to Beast...',
progress: generate ? 12 : 8
})
if (index === 0 && !generate && image) {
const filled = await applyImageFill({
image,
mask: params.mode === 'refine' ? mask : null,
aspect: params.aspect || 'auto'
})
image = filled.image
current = filled.image
if (filled.mask) {
mask = filled.mask
uploadedMask = await uploadImage({
...filled.mask,
filename: `mask_${filled.mask.filename || 'refine-mask.png'}`
}, job.id)
}
padded = filled.padded
prompt = composeFillPrompt(prompt, padded)
if (padded) {
emitChainJob(job, {
type: 'status',
message: `Filling to ${filled.aspect} without stretching...`,
progress: 7
})
}
}
if (index === 0 && params.mode === 'refine' && mask && !uploadedMask) {
uploadedMask = await uploadImage({
...mask,
filename: `mask_${mask.filename || 'refine-mask.png'}`
}, job.id)
}
const uploaded = generate || !image ? null : await uploadImage(image, job.id)
if (job.status === 'cancelled') throw new Error('Job interrupted.')
if (!generate) {
emitChainJob(job, {
type: 'status',
message: event.message,
progress: event.progress,
step: event.step,
maxStep: event.maxStep || params.steps,
node: event.node
message: params.mode === 'refine'
? `Queueing ${engineLabel} v2 refine on Beast...`
: params.mode === 'compose'
? `Queueing ${engineLabel} v2 compose (${params.task}) on Beast...`
: `Queueing ${engineLabel} v2 edit on Beast...`,
progress: 12
})
},
isCancelled: () => job.status === 'cancelled'
})
}
emitChainJob(job, { type: 'status', message: `Saving ${engineLabel} v2 still...`, progress: 94 })
const buffer = await downloadEditedImage(output)
const size = imageDimensions(buffer)
const still = await saveStill({
ownerKey: library.ownerKey,
folderId: library.folderId,
filename: library.name ? `${library.name}.png` : output.filename,
data: buffer,
width: size?.width || 0,
height: size?.height || 0,
hideInput: job.hideThumbnail === true,
role: 'output',
name: library.name || undefined,
prompt: params.prompt,
settings: {
kind: 'imagev2',
const built = buildImageV2Workflow({
mode: params.mode,
engine,
task: params.task,
prompt,
negative: params.negative,
imageAName: uploaded?.name,
imageBName: uploadedRef?.name,
maskName: uploadedMask?.name,
strength: params.strength,
snofsModel: params.snofsModel,
snofsClip: params.snofsClip,
consistencyModel: params.consistencyModel,
consistencyClip: params.consistencyClip,
steps: params.steps,
cfg: params.cfg,
megapixels: params.mode === 'generate' ? undefined : params.megapixels,
seed,
megapixels: params.megapixels,
width: params.width,
height: params.height,
turbo: params.turbo === true,
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,
...persistLoraFields(params.loraStack)
}
})
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, uploadedMask?.name].filter((name): name is string => Boolean(name)),
promptId: job.promptId
})
filenamePrefix: chainTotal > 1 ? `${comfyJobPrefix(job.id)}_p${index + 1}` : comfyJobPrefix(job.id),
kreaUnetName: krea?.unet,
kreaClipName: krea?.clip,
kreaVaeName: krea?.vae,
kreaConceptLora: krea?.conceptLora,
loraStack: params.loraStack
})
const queued = await queuePrompt(built.graph, job.clientId)
job.promptId = queued.prompt_id
job.status = 'running'
emitChainJob(job, {
type: 'status',
message: `Running ${built.workflowFile}...`,
progress: 18,
maxStep: params.steps
})
job.status = 'complete'
emitChainJob(job, {
type: 'complete',
message: `${engineLabel} v2 finished on Beast`,
progress: 100,
stillId: still?.id,
filename: output.filename,
subfolder: output.subfolder,
mediaType: 'image',
hideThumbnail: job.hideThumbnail,
folderLocked: library.folderLocked
})
const output = await waitForImageEdit({
promptId: queued.prompt_id,
clientId: job.clientId,
timeoutMs: 10 * 60 * 1000,
engineLabel,
nodeLabel: (node) => {
if (node === '15' || node === '19') return `Sampling ${engineLabel}`
return IMAGE_V2_NODE_LABELS[node]
},
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 ${engineLabel} v2 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,
role: 'output',
name: passName || undefined,
prompt,
familyId: library.familyId,
parentStillId,
chainIndex: index,
settings: {
kind: 'imagev2',
mode: params.mode,
engine,
task: params.task,
negative: params.negative,
snofsModel: params.snofsModel,
snofsClip: params.snofsClip,
consistencyModel: params.consistencyModel,
consistencyClip: params.consistencyClip,
steps: params.steps,
cfg: params.cfg,
megapixels: params.mode === 'generate' ? undefined : params.megapixels,
turbo: params.turbo === true,
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,
sourceStillId: params.sourceStillId || library.stillId,
referenceStillId: params.referenceStillId,
...persistLoraFields(params.loraStack)
}
})
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: last
? [uploadedRef?.name, uploadedMask?.name].filter((name): name is string => Boolean(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'
}
if (library.stopAfterCurrent === true) break
continue
}
job.status = 'complete'
emitChainJob(job, {
type: 'complete',
message: chainTotal > 1
? `${engineLabel} v2 chain finished on Beast`
: `${engineLabel} v2 finished on Beast`,
progress: 100,
stillId: still?.id,
filename: output.filename,
subfolder: output.subfolder,
mediaType: 'image',
hideThumbnail: job.hideThumbnail,
folderLocked: library.folderLocked
})
}
})
} catch (error) {
if (job.status !== 'error' && job.status !== 'cancelled') {
+18
View File
@@ -1391,6 +1391,15 @@ export function moveStillFamily(owner: string, id: string, folderId: string) {
})
}
export function setStillHidden(owner: string, id: string, hide: boolean) {
return mutate(owner, (catalog) => {
const still = catalog.stills.find(item => item.id === id)
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
still.hideInput = hide === true
return still
})
}
export function renameStill(owner: string, id: string, name: string) {
const trimmed = name.trim().slice(0, 80)
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
@@ -1594,6 +1603,15 @@ export function moveClipFamily(owner: string, id: string, folderId: string) {
})
}
export function setClipHidden(owner: string, id: string, hide: boolean) {
return mutate(owner, (catalog) => {
const clip = catalog.clips.find(item => item.id === id)
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
clip.hideThumbnail = hide === true
return clip
})
}
export function patchClipMeta(owner: string, id: string, patch: {
globalLocks?: string
permanenceRefs?: PermanenceRef[]
+4 -1
View File
@@ -780,7 +780,9 @@ async function startStudioEditJob(item: StudioJob) {
familyId: item.familyId,
chainIndex: 0,
chainStep: 1,
chainTotal: 1,
chainTotal: 1 + ((mode === 'edit' || mode === 'compose') ? (payload.passes?.length || 0) : 0),
chainLabel: (mode === 'edit' || mode === 'compose') && payload.passes?.length ? 'Pass 1' : undefined,
passes: payload.passes,
...persistLoraFields(payload.loraStack || payload.loraName)
}
})
@@ -793,6 +795,7 @@ async function startStudioEditJob(item: StudioJob) {
reference: mode === 'refine' || mode === 'generate' ? null : reference,
mask,
prompt: payload.prompt,
passes: payload.passes || [],
negative: payload.negative || '',
steps: payload.steps,
seed: live.library?.seed || payload.seed,