Overhaul Studio controls and add image batch from one still.

Video and Image pickers, thumbwheels, aspect icons, and a Scale to MP paddle; extra prompts default to batch so each run starts from the original image.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-30 23:16:19 -05:00
co-authored by Cursor
parent 8df265cbf3
commit 755aaeede8
17 changed files with 791 additions and 233 deletions
+21 -11
View File
@@ -37,6 +37,7 @@ export type EditV2RunParams = {
referenceStillId?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
passes?: { prompt: string }[]
passMode?: 'batch' | 'chain'
}
export async function runEditV2(job: Job, params: EditV2RunParams) {
@@ -61,6 +62,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
: []
const prompts = [params.prompt, ...extraPasses.map(item => item.prompt)]
const chainTotal = prompts.length
const batch = params.passMode !== 'chain'
const unit = batch ? 'Batch' : 'Pass'
library.chainTotal = chainTotal
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
@@ -83,13 +86,14 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
const { applyImageFill } = await import('~/server/utils/imagePad')
const { composeFillPrompt } = await import('~/utils/imageAspect')
let current = params.image || null
let startPadded = false
let mask = params.mask
let parentStillId: string | undefined
let uploadedRef: { name: string } | null = null
let uploadedMask: { name: string } | null = null
await ensureComfyLoraNames('image')
if (!generate) await assertImageScaleToTotalPixelsNode()
if (!generate && params.megapixels > 0) await assertImageScaleToTotalPixelsNode()
const krea = engine === 'krea' ? await resolveKreaGenerateAssets() : null
if (!generate && params.mode === 'compose' && params.reference) {
@@ -108,7 +112,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
library.seed = seed
library.chainIndex = index
library.chainStep = index + 1
library.chainLabel = chainTotal > 1 ? `Pass ${index + 1}` : undefined
library.chainLabel = chainTotal > 1 ? `${unit} ${index + 1}` : undefined
const passName = stillChainName(library.name || '', index)
let prompt = prompts[index]
let image = current
@@ -151,6 +155,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
}, job.id)
}
padded = filled.padded
startPadded = filled.padded
prompt = composeFillPrompt(prompt, padded)
if (padded) {
emitChainJob(job, {
@@ -159,6 +164,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
progress: 7
})
}
} else if (batch && startPadded) {
prompt = composeFillPrompt(prompt, true)
}
if (index === 0 && params.mode === 'refine' && mask && !uploadedMask) {
@@ -258,7 +265,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
name: passName || undefined,
prompt,
familyId: library.familyId,
parentStillId,
parentStillId: batch ? (params.sourceStillId || library.stillId) : parentStillId,
chainIndex: index,
settings: {
kind: 'imagev2',
@@ -272,7 +279,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
consistencyClip: params.consistencyClip,
steps: params.steps,
cfg: params.cfg,
megapixels: params.mode === 'generate' ? undefined : params.megapixels,
megapixels: params.mode === 'generate' || !(params.megapixels > 0) ? undefined : params.megapixels,
scaleToTotalPixels: params.mode === 'generate' ? undefined : params.megapixels > 0,
turbo: params.turbo === true,
strength: params.mode === 'generate' ? undefined : params.strength,
width: params.mode === 'generate' ? params.width : undefined,
@@ -285,7 +293,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
}
})
job.stillId = still?.id
parentStillId = still?.id
if (!batch) parentStillId = still?.id
await purgeComfyArtifacts({
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
imageName: uploaded?.name,
@@ -299,16 +307,18 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
if (!last) {
emitChainJob(job, {
type: 'checkpoint',
message: `Pass ${index + 1} saved`,
message: `${unit} ${index + 1} of ${chainTotal} 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 (!batch) {
current = {
filename: still?.filename || `pass_${index + 1}.png`,
data: buffer,
type: 'image/png'
}
}
if (library.stopAfterCurrent === true) break
continue
@@ -318,7 +328,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
emitChainJob(job, {
type: 'complete',
message: chainTotal > 1
? `${engineLabel} v2 chain finished on Beast`
? `${engineLabel} v2 ${batch ? 'batch' : 'chain'} finished on Beast`
: `${engineLabel} v2 finished on Beast`,
progress: 100,
stillId: still?.id,