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
+2 -1
View File
@@ -140,7 +140,8 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
consistencyClip: clampImageV2Strength(rec.consistencyClip ?? rec.consistency_clip, mode === 'generate' ? 0 : IMAGE_V2_CONSISTENCY_CLIP),
steps: engine === 'krea' ? clampImageSteps(rec.steps, IMAGE_V2_KREA_STEPS) : turbo ? 8 : clampImageSteps(rec.steps, IMAGE_V2_STEPS_DEFAULT),
cfg: engine === 'krea' ? clampImageCfg(rec.cfg, IMAGE_V2_KREA_CFG) : turbo ? 1 : clampImageCfg(rec.cfg, IMAGE_V2_CFG_DEFAULT),
megapixels: mode === 'generate' ? undefined : clampImageScaleMegapixels(rec.megapixels ?? rec.scaleMegapixels, 1),
megapixels: mode === 'generate' || rec.scaleToTotalPixels === false ? undefined : clampImageScaleMegapixels(rec.megapixels ?? rec.scaleMegapixels, 1),
scaleToTotalPixels: mode === 'generate' ? undefined : rec.scaleToTotalPixels === false ? false : true,
turbo,
strength: mode === 'generate' || (mode !== 'refine' && engine !== 'krea')
? undefined
+17 -10
View File
@@ -25,6 +25,7 @@ export type EditRunParams = {
loraStack?: import('~/utils/loras').LoraStackItem[]
scaleToTotalPixels?: boolean
scaleMegapixels?: number
passMode?: 'batch' | 'chain'
}
export async function runEdit(job: Job, params: EditRunParams) {
@@ -32,6 +33,8 @@ export async function runEdit(job: Job, params: EditRunParams) {
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
const batch = params.passMode !== 'chain'
const unit = batch ? 'Batch' : 'Pass'
library.chainTotal = chainTotal
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
@@ -63,7 +66,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
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 = index === 0 ? composeFillPrompt(prompts[index], filled.padded) : prompts[index]
const prompt = (batch || index === 0) ? composeFillPrompt(prompts[index], filled.padded) : prompts[index]
const last = index === prompts.length - 1
if (index > 0) {
await ensureComfyReady((status) => {
@@ -80,7 +83,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
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 reference = index === 0 ? params.reference : null
const dual = Boolean(reference)
const passName = stillChainName(library.name || '', index)
@@ -163,7 +166,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
name: passName || undefined,
prompt,
familyId: library.familyId,
parentStillId,
parentStillId: batch ? library.stillId : parentStillId,
chainIndex: index,
settings: {
kind: 'image',
@@ -180,7 +183,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
}
})
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,
@@ -192,16 +195,18 @@ export async function runEdit(job: Job, params: EditRunParams) {
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
@@ -210,7 +215,9 @@ export async function runEdit(job: Job, params: EditRunParams) {
job.status = 'complete'
emitChainJob(job, {
type: 'complete',
message: chainTotal > 1 ? 'Edit chain finished on Beast' : 'Edit finished on Beast',
message: chainTotal > 1
? (batch ? 'Edit batch finished on Beast' : 'Edit chain finished on Beast')
: 'Edit finished on Beast',
progress: 100,
stillId: still?.id,
filename: output.filename,
+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,
+32 -4
View File
@@ -110,6 +110,34 @@ function patchScaleMegapixels(graph: WorkflowGraph, megapixels: number) {
}
}
function linkSource(value: unknown): string | null {
return Array.isArray(value) && typeof value[0] === 'string' ? value[0] : null
}
function stripScaleNodes(graph: WorkflowGraph) {
for (const [id, node] of Object.entries(graph)) {
if (node.class_type !== IMAGE_SCALE_TO_TOTAL_PIXELS) continue
const src = Array.isArray(node.inputs.image) ? node.inputs.image : null
for (const [otherId, other] of Object.entries(graph)) {
if (otherId === id) continue
for (const [key, value] of Object.entries(other.inputs)) {
if (linkSource(value) !== id || !Array.isArray(value)) continue
other.inputs[key] = src ? [src[0], value[1] === 0 ? src[1] : value[1]] : value
}
}
delete graph[id]
}
}
function applyScaleMegapixels(graph: WorkflowGraph, megapixels?: number) {
const mp = Number(megapixels)
if (Number.isFinite(mp) && mp > 0) {
patchScaleMegapixels(graph, mp)
return
}
stripScaleNodes(graph)
}
function graphHasMaskInput(graph: WorkflowGraph) {
return Object.values(graph).some((node) => {
const mask = node.inputs?.mask
@@ -401,7 +429,7 @@ function buildKreaItiWorkflow(params: ImageV2BuildParams) {
setInput(graph, '15', 'scheduler', 'simple')
setInput(graph, '15', 'denoise', denoise)
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || `v2-krea-${params.mode}`)
patchScaleMegapixels(graph, params.megapixels ?? 1)
applyScaleMegapixels(graph, params.megapixels)
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'krea'), 'krea')
assertImageV2Graph(graph, params.mode, compose ? params.imageBName : undefined, 'krea')
@@ -420,7 +448,7 @@ function buildKreaItiWorkflow(params: ImageV2BuildParams) {
steps,
cfg,
seed: params.seed,
megapixels: clampImageScaleMegapixels(params.megapixels ?? 1)
megapixels: Number(params.megapixels) > 0 ? clampImageScaleMegapixels(params.megapixels) : 0
}))
return { graph, workflowFile, loaders, prompt, strength: denoise }
}
@@ -513,7 +541,7 @@ export function buildImageV2Workflow(params: ImageV2BuildParams) {
setInput(graph, SCHEDULER, 'steps', steps)
setInput(graph, CFG, 'cfg', cfg)
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || (generate ? 'v2-generate' : 'v2'))
if (!generate) patchScaleMegapixels(graph, params.megapixels ?? 1)
if (!generate) applyScaleMegapixels(graph, params.megapixels)
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'flux'), 'flux')
const guider = Object.values(graph).find(node => node.class_type === 'CFGGuider' || node.class_type === 'BasicGuider')
@@ -538,7 +566,7 @@ export function buildImageV2Workflow(params: ImageV2BuildParams) {
steps,
cfg,
seed: params.seed,
megapixels: generate ? undefined : clampImageScaleMegapixels(params.megapixels ?? 1)
megapixels: generate ? undefined : (Number(params.megapixels) > 0 ? clampImageScaleMegapixels(params.megapixels) : 0)
}))
return { graph, workflowFile, loaders, prompt, strength }
+8 -3
View File
@@ -47,6 +47,7 @@ export interface StudioJobPayload {
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
negative?: string
passes?: { prompt: string }[]
passMode?: 'batch' | 'chain'
referenceStillId?: string
referenceStillFilename?: string
scaleToTotalPixels?: boolean
@@ -781,7 +782,9 @@ async function startStudioEditJob(item: StudioJob) {
chainIndex: 0,
chainStep: 1,
chainTotal: 1 + ((mode === 'edit' || mode === 'compose') ? (payload.passes?.length || 0) : 0),
chainLabel: (mode === 'edit' || mode === 'compose') && payload.passes?.length ? 'Pass 1' : undefined,
chainLabel: (mode === 'edit' || mode === 'compose') && payload.passes?.length
? (payload.passMode === 'chain' ? 'Pass 1' : 'Batch 1')
: undefined,
passes: payload.passes,
...persistLoraFields(payload.loraStack || payload.loraName)
}
@@ -796,6 +799,7 @@ async function startStudioEditJob(item: StudioJob) {
mask,
prompt: payload.prompt,
passes: payload.passes || [],
passMode: payload.passMode === 'chain' ? 'chain' : 'batch',
negative: payload.negative || '',
steps: payload.steps,
seed: live.library?.seed || payload.seed,
@@ -804,7 +808,7 @@ async function startStudioEditJob(item: StudioJob) {
snofsClip: payload.snofsClip ?? 0,
consistencyModel: payload.consistencyModel ?? (mode === 'generate' || payload.engine === 'krea' ? 0 : 0.7),
consistencyClip: payload.consistencyClip ?? (mode === 'generate' || payload.engine === 'krea' ? 0 : 0.7),
megapixels: payload.scaleMegapixels ?? 1,
megapixels: payload.scaleToTotalPixels === false ? 0 : (payload.scaleMegapixels ?? 1),
strength: payload.refineStrength,
aspect: payload.aspect || 'auto',
width: payload.width,
@@ -848,7 +852,7 @@ async function startStudioEditJob(item: StudioJob) {
chainIndex: 0,
chainStep: 1,
chainTotal: 1 + passes.length,
chainLabel: passes.length ? 'Pass 1' : undefined,
chainLabel: passes.length ? (payload.passMode === 'chain' ? 'Pass 1' : 'Batch 1') : undefined,
passes,
...persistLoraFields(payload.loraStack || payload.loraName)
}
@@ -860,6 +864,7 @@ async function startStudioEditJob(item: StudioJob) {
reference,
prompt: payload.prompt,
passes,
passMode: payload.passMode === 'chain' ? 'chain' : 'batch',
negative: payload.negative || '',
steps: payload.steps,
seed: live.library?.seed || payload.seed,