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:
+236
-160
@@ -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') {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user