Add per-image iteration settings and steps/CFG sweeps

This commit is contained in:
Towsty
2026-09-06 17:02:42 -05:00
parent 0c6a035d04
commit 666c922f40
12 changed files with 370 additions and 59 deletions
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="mt-3 space-y-3 border-t border-white/10 pt-3">
<p class="text-xs text-zinc-400">Blank values use the shared settings. A blank prompt uses the first prompt.</p>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<label class="text-sm text-zinc-300">Steps
<input :value="modelValue.steps" type="number" min="1" max="50" step="1" :placeholder="String(baseSteps)" class="iteration-input" :disabled="disabled" @input="numberField('steps', $event)">
</label>
<label class="text-sm text-zinc-300">CFG
<input :value="modelValue.cfg" type="number" min="0" max="20" step="0.1" :placeholder="String(baseCfg)" class="iteration-input" :disabled="disabled" @input="numberField('cfg', $event)">
</label>
<label class="text-sm text-zinc-300">Seed
<select :value="seedMode" class="iteration-input" :disabled="disabled" @change="setSeedMode($event)">
<option value="random">Random</option><option value="same">Same as first</option><option value="custom">Custom</option>
</select>
</label>
<label v-if="seedMode === 'custom'" class="text-sm text-zinc-300">Seed number
<input :value="modelValue.seed" type="number" min="0" max="2147483647" step="1" class="iteration-input" :disabled="disabled" @input="numberField('seed', $event)">
</label>
<label v-if="allowStrength" class="text-sm text-zinc-300">Denoise
<input :value="modelValue.strength" type="number" min="0.15" max="0.75" step="0.01" placeholder="Shared" class="iteration-input" :disabled="disabled" @input="numberField('strength', $event)">
</label>
</div>
<label class="flex items-center gap-2 text-sm text-zinc-300">
<input type="checkbox" :checked="modelValue.loraStack === undefined" :disabled="disabled" @change="inheritLoras($event)"> Use shared LoRAs
</label>
<LoraSelect v-if="modelValue.loraStack !== undefined" :model-value="modelValue.loraStack" :names="loraNames" :engine="engine" :disabled="disabled" label="This variation's LoRAs" empty-label="No LoRAs" @update:model-value="update({ loraStack: $event })" />
<p v-if="modelValue.steps !== undefined || modelValue.cfg !== undefined" class="text-xs text-zinc-400">Custom steps or CFG override the shared Turbo preset for this variation.</p>
</div>
</template>
<script setup lang="ts">
import type { ImageIteration } from '~/utils/imageIterations'
const props = withDefaults(defineProps<{ modelValue: ImageIteration; baseSteps?: number; baseCfg?: number; loraNames?: string[]; engine?: 'flux' | 'krea'; allowStrength?: boolean; disabled?: boolean }>(), { baseSteps: 20, baseCfg: 1, loraNames: () => [], engine: 'flux', allowStrength: false, disabled: false })
const emit = defineEmits<{ 'update:modelValue': [value: ImageIteration] }>()
const seedMode = computed(() => typeof props.modelValue.seed === 'number' ? 'custom' : props.modelValue.seed || 'random')
function update(patch: Partial<ImageIteration>) { emit('update:modelValue', { ...props.modelValue, ...patch }) }
function numberField(key: 'steps' | 'cfg' | 'seed' | 'strength', event: Event) {
const value = (event.target as HTMLInputElement).value
update({ [key]: value === '' ? undefined : Number(value) })
}
function setSeedMode(event: Event) {
const mode = (event.target as HTMLSelectElement).value
update({ seed: mode === 'custom' ? 0 : mode === 'same' ? 'same' : 'random' })
}
function inheritLoras(event: Event) { update({ loraStack: (event.target as HTMLInputElement).checked ? undefined : [] }) }
</script>
<style scoped>
.iteration-input { @apply mt-1 w-full rounded-lg border border-white/15 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none focus:ring-2 focus:ring-amber-300/40 disabled:opacity-50; }
</style>
+29
View File
@@ -0,0 +1,29 @@
<template>
<details class="rounded-2xl border border-amber-300/25 bg-amber-400/5 p-4">
<summary class="cursor-pointer text-sm font-semibold text-amber-100">Compare settings · build a sweep</summary>
<p class="mt-3 text-sm text-zinc-300">Use one prompt with several step counts or CFG values. Each combination becomes an editable variation.</p>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<label class="text-sm text-zinc-300">Steps, separated by commas
<input v-model="steps" class="mt-1 w-full rounded-lg border border-white/15 bg-zinc-950 px-3 py-2" placeholder="24, 20, 18, 19">
</label>
<label class="text-sm text-zinc-300">CFG values
<input v-model="cfg" class="mt-1 w-full rounded-lg border border-white/15 bg-zinc-950 px-3 py-2" placeholder="1.5">
</label>
</div>
<p class="mt-3 text-sm text-zinc-300">{{ preview }}</p>
<p class="mt-1 text-xs text-zinc-400">Uses the same seed for comparison and the original input for every image. Replaces the current variations; nothing renders until you queue.</p>
<button type="button" class="mt-3 rounded-lg bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950 disabled:opacity-50" :disabled="!rows.length" @click="emit('apply', rows)">Build {{ rows.length || '' }} variations</button>
</details>
</template>
<script setup lang="ts">
import { imageIterationSweep, type ImageIteration } from '~/utils/imageIterations'
const emit = defineEmits<{ apply: [rows: ImageIteration[]] }>()
const steps = ref('24, 20, 18, 19')
const cfg = ref('1.5')
const result = computed(() => {
try { return { rows: imageIterationSweep(steps.value, cfg.value), error: '' } }
catch (error) { return { rows: [] as ImageIteration[], error: error instanceof Error ? error.message : String(error) } }
})
const rows = computed(() => result.value.rows)
const preview = computed(() => result.value.error || `${rows.value.length} images · ${rows.value.map(row => `${row.steps} steps / CFG ${row.cfg}`).join(' · ')}`)
</script>
+11
View File
@@ -76,6 +76,17 @@
<div v-for="(shot, index) in extraShots" :key="index" class="rounded-2xl border border-white/10 bg-zinc-950/50 px-3 py-3">
<p class="text-sm font-medium text-zinc-200">{{ job.kind === 'edit' ? `Pass ${index + 2}` : `Shot ${index + 2}` }}</p>
<textarea v-model="shot.prompt" rows="3" class="mt-2 max-h-36 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70" :disabled="!editable" />
<ImageIterationSettings
v-if="job.kind === 'edit' && job.payload.v2Mode === 'iterate'"
:model-value="shot"
:base-steps="draft.steps"
:base-cfg="draft.cfg"
:lora-names="loraNames"
:engine="job.payload.engine === 'krea' ? 'krea' : 'flux'"
:allow-strength="job.payload.engine === 'krea' && Boolean(job.payload.stillId)"
:disabled="!editable"
@update:model-value="extraShots[index] = { ...$event, duration: shot.duration }"
/>
<label v-if="job.kind !== 'edit'" class="mt-2 block text-xs text-zinc-400">
Duration
<input v-model.number="shot.duration" type="number" min="0.5" max="120" step="0.1" class="mt-1 w-28 rounded-lg border border-white/10 bg-zinc-950 px-2 py-1 text-sm disabled:opacity-70" :disabled="!editable">
+43 -8
View File
@@ -326,7 +326,7 @@
</label>
</div>
<p v-if="studioMode === 'editv2' && v2Mode === 'generate'" class="mt-3 text-xs text-zinc-500">No reference image. Prompt only.</p>
<p v-else-if="studioMode === 'editv2' && v2Mode === 'iterate'" class="mt-3 text-xs text-zinc-500">Add more iterations below. Everything saves as one nested library collection.</p>
<p v-else-if="studioMode === 'editv2' && v2Mode === 'iterate'" class="mt-3 text-xs text-zinc-500">Use one prompt with different steps, CFG, seeds, and LoRAs below. Everything saves as one library collection.</p>
<div v-if="studioMode === 'editv2' && v2Mode === 'refine'" class="mt-3 space-y-2">
<p class="text-xs text-zinc-500">Prompt only what should change in the painted area.</p>
<div class="flex flex-wrap gap-2">
@@ -380,6 +380,7 @@
</div>
</div>
<div v-if="studioMode === 'edit' || v2PassChaining" class="mt-3 space-y-3">
<ImageIterationSweep v-if="studioMode === 'editv2' && v2Mode === 'iterate'" @apply="applyIterationSweep" />
<div v-if="!(v2Mode === 'iterate' && !file)" class="grid grid-cols-2 gap-2">
<button
type="button"
@@ -435,6 +436,16 @@
:keep-items="activeKeepPrompts"
:keep-heading="keepPromptHeading"
/>
<ImageIterationSettings
v-if="studioMode === 'editv2' && v2Mode === 'iterate'"
:model-value="item"
:base-steps="v2Steps"
:base-cfg="v2Cfg"
:lora-names="imageV2LoraOptions"
:engine="v2Engine"
:allow-strength="v2Engine === 'krea' && Boolean(file)"
@update:model-value="editPassQueue[index] = { ...$event, id: item.id }"
/>
</div>
<button
type="button"
@@ -2242,6 +2253,7 @@
</template>
<script setup lang="ts">
import { normalizeImageIterations, type ImageIteration } from '~/utils/imageIterations'
import { composePromptParts, formatPromptParts, resolvePromptWrappers, restorePromptParts } from '~/utils/promptParts'
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
import { parseImageBatchScript, parseShotScript } from '~/utils/parseShots'
@@ -2496,7 +2508,7 @@ interface QueuedExtension {
loraStack?: LoraStackItem[]
}
interface QueuedEditPass {
interface QueuedEditPass extends ImageIteration {
id: string
prompt: string
}
@@ -3213,7 +3225,7 @@ const editV2Blocked = computed(() => {
if (v2Mode.value !== 'generate' && v2Mode.value !== 'iterate' && !file.value) return true
if (v2Mode.value === 'compose' && !editRefFile.value) return true
if (v2Mode.value === 'refine' && !refineMaskDirty.value) return true
if (v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) return true
if (v2Mode.value !== 'iterate' && v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) return true
if (v2Mode.value === 'iterate' && !editPassQueue.value.length) return true
return false
})
@@ -3226,8 +3238,8 @@ const editV2BlockReason = computed(() => {
? 'Write a prompt first.'
: 'Write a prompt, or pick Landscape / Portrait / Square to fill the frame.'
}
if (v2Mode.value === 'iterate' && !editPassQueue.value.length) return 'Add at least one more iteration prompt.'
if (v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) return 'Each extra pass needs a prompt.'
if (v2Mode.value === 'iterate' && !editPassQueue.value.length) return 'Add a variation or build a steps/CFG sweep.'
if (v2Mode.value !== 'iterate' && v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) return 'Each extra pass needs a prompt.'
if (!folderId.value) return 'Choose a library folder before generating.'
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image v2 is not configured. Set COMFY_HOST.'
return ''
@@ -3265,7 +3277,7 @@ const extraPassHint = computed(() => {
return 'Each extra pass is another Edit on the last output. You cannot switch graphs between passes.'
})
const extraPassPlaceholder = computed(() => {
if (v2Mode.value === 'iterate') return 'Next iteration prompt…'
if (v2Mode.value === 'iterate') return 'Leave blank to use the first prompt…'
if (imagePassMode.value === 'batch') return 'Another prompt for the same start still…'
return studioMode.value === 'editv2' && v2Mode.value === 'compose'
? 'Next compose prompt, applied to the previous pass output…'
@@ -5481,6 +5493,9 @@ async function restoreStudioEditJob(payload: {
cfg?: number
turbo?: boolean
referenceStillId?: string
passes?: ImageIteration[]
passMode?: ImagePassMode
seed?: number
}) {
closeExtend()
applyPromptRestore(payload.prompt || '', payload)
@@ -5489,6 +5504,10 @@ async function restoreStudioEditJob(payload: {
folderId.value = payload.folderId
browseFolderId.value = payload.folderId
}
editPassQueue.value = (payload.passes || []).map(pass => ({ ...pass, id: crypto.randomUUID() }))
imagePassMode.value = payload.passMode || 'batch'
imageBatchScript.value = ''
seedInput.value = payload.seed == null ? '' : String(payload.seed)
const v2 = payload.imagePipeline === 'v2'
if (v2) {
studioMode.value = 'editv2'
@@ -5612,6 +5631,8 @@ async function restoreStudioPayload(kind: string, payload: {
scaleMegapixels?: number
steps?: number
referenceStillId?: string
passes?: ImageIteration[]
passMode?: ImagePassMode
}) {
if (kind === 'edit' || payload.imagePipeline === 'v2') {
await restoreStudioEditJob(payload)
@@ -6965,6 +6986,18 @@ function removeQueuedExtension(id: string) {
extensionQueue.value = extensionQueue.value.filter(item => item.id !== id)
}
function applyIterationSweep(rows: ImageIteration[]) {
if (rows.length < 2) { toast('Use at least two variations for Iterate.'); return }
v2Steps.value = rows[0].steps!
v2Cfg.value = rows[0].cfg!
v2Turbo.value = false
imagePassMode.value = 'batch'
if (!seedInput.value || seedInput.value === 'random') seedInput.value = String(Math.floor(Math.random() * 2_147_483_647))
editPassQueue.value = rows.slice(1).map(row => ({ ...row, id: crypto.randomUUID() }))
imageBatchScript.value = ''
toast(rows.length + ' variations ready. The first uses the shared settings; each following variation can be edited.')
}
function queueEditPass() {
editPassQueue.value.push({
id: crypto.randomUUID(),
@@ -7271,12 +7304,14 @@ async function editImageV2() {
toast('Paint a mask on Still A first. Refine will not fall back to Edit.')
return
}
if (v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) {
if (v2Mode.value !== 'iterate' && v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) {
toast('Each extra pass needs a prompt.')
return
}
absorbPastedImageBatch()
const extraPasses = v2PassChaining.value ? editPassQueue.value.map(item => ({ prompt: item.prompt.trim() })) : []
let extraPasses: ImageIteration[]
try { extraPasses = v2PassChaining.value ? normalizeImageIterations(editPassQueue.value) : [] }
catch (error) { toast(error instanceof Error ? error.message : String(error)); return }
const hideOut = hideThumbnail.value
try {
await ensureGenerationNotifyPermission()
+13 -3
View File
@@ -1,3 +1,5 @@
import { normalizeImageIterations } from '~/utils/imageIterations'
import { assertImageV2LoraStack, parsePostedLoraStack } from '~/server/utils/loras'
import { persistLoraFields, normalizeLoraStack } from '~/utils/loras'
import { listStudioJobs, patchStudioJob, type StudioJobPayload } from '~/server/utils/studioQueue'
@@ -32,6 +34,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, unknown>>(event).catch(() => ({}))
const job = await patchStudioJob(owner, id, (row) => {
if (!EDITABLE.has(row.status)) throw createError({ statusCode: 409, statusMessage: 'That job has started generating.' })
const payload = row.payload
if (body.name != null) {
const name = text(body.name, 80).trim()
@@ -75,9 +78,16 @@ export default defineEventHandler(async (event) => {
payload.loraStack = fields.loraStack
}
if (Array.isArray(body.passes) && row.kind === 'edit') {
payload.passes = body.passes.map((item) => ({
prompt: text((item as { prompt?: unknown })?.prompt)
}))
try {
payload.passes = normalizeImageIterations(body.passes)
if (payload.imagePipeline === 'v2') {
for (const pass of payload.passes) {
if (pass.loraStack !== undefined) pass.loraStack = assertImageV2LoraStack(parsePostedLoraStack(pass.loraStack, 'image'), payload.engine || 'flux')
}
}
} catch (error) {
throw createError({ statusCode: 400, statusMessage: error instanceof Error ? error.message : String(error) })
}
row.shotCount = 1 + payload.passes.length
}
if (Array.isArray(body.extensions) && row.kind !== 'edit') {
+8 -10
View File
@@ -1,3 +1,4 @@
import { normalizeImageIterations } from '~/utils/imageIterations'
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
import { comfyConfigured } from '~/server/utils/comfy'
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
@@ -35,15 +36,9 @@ 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 [] }
try { return normalizeImageIterations(raw) } catch (error) {
throw createError({ statusCode: 400, statusMessage: error instanceof Error ? error.message : String(error) })
}
if (!Array.isArray(parsed)) return []
return parsed
.map((item: { prompt?: unknown }) => ({ prompt: String(item?.prompt || '').trim() }))
.filter(item => item.prompt)
}
function parseBool(raw: unknown) {
@@ -157,10 +152,10 @@ export default defineEventHandler(async (event) => {
if (mode === 'iterate' && !extraPasses.length) {
throw createError({
statusCode: 400,
statusMessage: 'Iterate needs at least two prompts. Add more iterations, or use Generate / Edit for a single run.'
statusMessage: 'Iterate needs at least two images. Add a variation or build a steps/CFG sweep.'
})
}
if (extraPasses.some(item => !item.prompt)) {
if (mode !== 'iterate' && extraPasses.some(item => !item.prompt)) {
throw createError({ statusCode: 400, statusMessage: 'Each extra pass needs a prompt' })
}
if (!comfyConfigured()) {
@@ -278,6 +273,9 @@ export default defineEventHandler(async (event) => {
const generateSnofsClipFallback = 0
await listStudioLoras()
const loraStack = assertImageV2LoraStack(parsePostedLoraStack(fields.loraStack || fields.lora, 'image'), engine)
for (const pass of extraPasses) {
if (pass.loraStack !== undefined) pass.loraStack = assertImageV2LoraStack(parsePostedLoraStack(pass.loraStack, 'image'), engine)
}
const stackSpecials = imageV2StackSpecials(loraStack, engine)
const snofsModel = conceptAllowed
? (stackSpecials.snofs?.strengthModel ?? clampImageV2Strength(fields.snofs_model, generateSnofsFallback))
+42 -30
View File
@@ -1,3 +1,4 @@
import { normalizeImageIterations, resolveImageIteration } from '~/utils/imageIterations'
import { createJob, emitJob, type Job } from '~/server/utils/jobs'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import { assertImageScaleToTotalPixelsNode, comfyImagePrefix, getComfyHost, uploadImage, queuePrompt } from '~/server/utils/comfy'
@@ -46,7 +47,7 @@ export type EditV2RunParams = {
sourceStillId?: string
referenceStillId?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
passes?: { prompt: string }[]
passes?: import('~/utils/imageIterations').ImageIteration[]
passMode?: 'batch' | 'chain'
}
@@ -72,7 +73,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
const canChain = params.mode === 'iterate' || params.mode === 'edit' || params.mode === 'compose'
const extraPasses = canChain
? (params.passes || []).map(item => ({ prompt: String(item.prompt || '').trim() })).filter(item => item.prompt)
? normalizeImageIterations(params.passes)
: []
const prompts = [
library.promptMid || restorePromptParts(params.prompt, {
@@ -80,7 +81,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
prompt: params.prompt,
post: library.promptPost
}).prompt,
...extraPasses.map(item => item.prompt)
...extraPasses.map(item => item.prompt || library.promptMid || library.prompt || params.prompt)
]
const chainTotal = prompts.length
// Text-only iterate is always independent generations. With a still, honor batch vs chain.
@@ -90,6 +91,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
try {
// Keep the studio slot across all iterations, including upload/save gaps.
library.chainContinuing = true
await ensureComfyReady((status) => {
emitChainJob(job, {
type: status.state === 'busy' ? 'busy' : 'status',
@@ -117,22 +120,6 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
await ensureComfyLoraNames('image')
if (!generate && params.megapixels > 0) await assertImageScaleToTotalPixelsNode()
const krea = engine === 'krea' ? await resolveKreaGenerateAssets() : null
const appliedStack = mergeLegacyImageV2Loras(params.loraStack?.length ? params.loraStack : library.loraStack, {
engine,
mode: graphMode,
xaigen: isXaigenStudio(),
snofsModel: params.snofsModel,
snofsClip: params.snofsClip,
consistencyModel: params.consistencyModel,
consistencyClip: params.consistencyClip,
kreaConcept: krea?.conceptLora
})
const appliedSpecials = imageV2StackSpecials(appliedStack, engine)
const snofsModel = appliedSpecials.snofs?.strengthModel ?? params.snofsModel
const snofsClip = appliedSpecials.snofs?.strengthClip ?? params.snofsClip
const consistencyModel = appliedSpecials.consistency?.strengthModel ?? params.consistencyModel
const consistencyClip = appliedSpecials.consistency?.strengthClip ?? params.consistencyClip
if (!generate && graphMode === 'compose' && params.reference) {
uploadedRef = await uploadImage({
...params.reference,
@@ -144,7 +131,28 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
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)
const pass = index > 0 ? extraPasses[index - 1] : undefined
const { steps, cfg, seed, strength } = resolveImageIteration(params, pass)
const turbo = pass?.steps !== undefined || pass?.cfg !== undefined ? false : params.turbo === true
const appliedStack = mergeLegacyImageV2Loras(pass?.loraStack ?? params.loraStack ?? library.loraStack, {
engine,
mode: graphMode,
xaigen: isXaigenStudio(),
snofsModel: pass?.loraStack !== undefined ? 0 : params.snofsModel,
snofsClip: pass?.loraStack !== undefined ? 0 : params.snofsClip,
consistencyModel: pass?.loraStack !== undefined ? 0 : params.consistencyModel,
consistencyClip: pass?.loraStack !== undefined ? 0 : params.consistencyClip,
kreaConcept: krea?.conceptLora
})
const appliedSpecials = imageV2StackSpecials(appliedStack, engine)
const snofsModel = appliedSpecials.snofs?.strengthModel ?? (pass?.loraStack !== undefined ? 0 : params.snofsModel)
const snofsClip = appliedSpecials.snofs?.strengthClip ?? (pass?.loraStack !== undefined ? 0 : params.snofsClip)
const consistencyModel = appliedSpecials.consistency?.strengthModel ?? (pass?.loraStack !== undefined ? 0 : params.consistencyModel)
const consistencyClip = appliedSpecials.consistency?.strengthClip ?? (pass?.loraStack !== undefined ? 0 : params.consistencyClip)
library.steps = steps
library.cfg = cfg
job.maxStep = steps
library.prompt = prompts[index]
library.seed = seed
library.chainIndex = index
@@ -238,19 +246,19 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
imageAName: uploaded?.name,
imageBName: uploadedRef?.name,
maskName: uploadedMask?.name,
strength: params.strength,
strength,
snofsModel,
snofsClip,
consistencyModel,
consistencyClip,
steps: params.steps,
cfg: params.cfg,
steps,
cfg,
seed,
megapixels: params.megapixels,
width: params.width,
height: params.height,
aspect: params.aspect,
turbo: params.turbo === true,
turbo,
filenamePrefix: chainTotal > 1 ? `${comfyImagePrefix()}_${comfyJobPrefix(job.id)}_p${index + 1}` : `${comfyImagePrefix()}_${comfyJobPrefix(job.id)}`,
kreaUnetName: krea?.unet,
kreaClipName: krea?.clip,
@@ -265,7 +273,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
type: 'status',
message: `Running ${built.workflowFile}...`,
progress: 18,
maxStep: params.steps
maxStep: steps
})
const output = await waitForImageEdit({
@@ -283,7 +291,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
message: event.message,
progress: event.progress,
step: event.step,
maxStep: event.maxStep || params.steps,
maxStep: event.maxStep || steps,
node: event.node
})
},
@@ -291,6 +299,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
})
emitChainJob(job, { type: 'status', message: `Saving ${engineLabel} v2 still...`, progress: 94 })
job.saving = true
const buffer = await downloadEditedImage(output)
const size = imageDimensions(buffer)
const still = await saveStill({
@@ -319,12 +328,12 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
snofsClip,
consistencyModel,
consistencyClip,
steps: params.steps,
cfg: params.cfg,
steps,
cfg,
megapixels: generate || !(params.megapixels > 0) ? undefined : params.megapixels,
scaleToTotalPixels: generate ? undefined : params.megapixels > 0,
turbo: params.turbo === true,
strength: generate ? undefined : params.strength,
turbo,
strength: generate ? undefined : strength,
width: generate ? params.width : undefined,
height: generate ? params.height : undefined,
aspect: params.aspect || 'auto',
@@ -346,6 +355,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
promptId: job.promptId
})
job.saving = false
if (!last) {
emitChainJob(job, {
type: 'checkpoint',
@@ -390,6 +400,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
emitJob(job, { type: 'error', error: message, message })
}
} finally {
job.saving = false
library.chainContinuing = false
const { onLiveVideoSettled } = await import('~/server/utils/studioQueue')
await onLiveVideoSettled(job)
}
+1 -1
View File
@@ -96,7 +96,7 @@ export interface Job {
parentStillId?: string
workflow?: import('~/utils/videoModels').VideoWorkflowId
chainContinuing?: boolean
passes?: { prompt: string }[]
passes?: import('~/utils/imageIterations').ImageIteration[]
queueId?: string
queueAutoRun?: boolean
queueBudget?: number
+3 -3
View File
@@ -52,7 +52,7 @@ export interface StudioJobPayload {
shotLoras?: string[]
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
negative?: string
passes?: { prompt: string }[]
passes?: import('~/utils/imageIterations').ImageIteration[]
passMode?: 'batch' | 'chain'
referenceStillId?: string
referenceStillFilename?: string
@@ -1207,7 +1207,7 @@ async function startStudioEditJob(item: StudioJob) {
height: payload.height,
steps: payload.steps,
turbo: payload.turbo === true,
seed: payload.seed || Math.floor(Math.random() * 2_147_483_647),
seed: payload.seed ?? Math.floor(Math.random() * 2_147_483_647),
cfg: payload.cfg,
stillId: payload.stillId,
stillFilename: payload.stillFilename,
@@ -1235,7 +1235,7 @@ async function startStudioEditJob(item: StudioJob) {
prompt: payload.prompt,
negative: payload.negative || '',
steps: payload.steps,
seed: payload.seed || Math.floor(Math.random() * 2_147_483_647),
seed: payload.seed ?? Math.floor(Math.random() * 2_147_483_647),
cfg: payload.cfg,
snofsModel: v2Specials.snofs?.strengthModel ?? payload.snofsModel ?? 0,
snofsClip: v2Specials.snofs?.strengthClip ?? payload.snofsClip ?? 0,
+88
View File
@@ -0,0 +1,88 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
function load(path, mocks = {}) {
const source = readFileSync(new URL('../' + path, import.meta.url), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const exports = {}
new Function('require', 'exports', code)(id => {
if (!(id in mocks)) throw Error('Unexpected dependency ' + id)
return mocks[id]
}, exports)
return exports
}
const iterations = load('utils/imageIterations.ts')
const { normalizeImageIterations: normalize, resolveImageIteration: resolve, imageIterationSweep: sweep } = iterations
test('requested sweep produces four ordered variations with one comparison seed', () => {
const rows = sweep('24, 20, 18, 19', '1.5')
assert.deepEqual(rows.map(row => row.steps), [24,20,18,19])
assert.ok(rows.every(row => row.cfg === 1.5 && row.seed === 'same'))
assert.equal(sweep('20 24', '1, 1.5').length, 4)
})
test('blank overrides inherit, explicit zero and no-LoRA remain explicit', () => {
const pass = normalize([{ prompt: '', steps: '', cfg: 0, seed: 0, loraStack: [] }])[0]
assert.equal(pass.steps, undefined)
assert.deepEqual(pass.loraStack, [])
assert.deepEqual(resolve({steps:24,cfg:1.5,seed:42},pass), {steps:24,cfg:0,seed:0,strength:undefined})
assert.equal(resolve({steps:24,cfg:1.5,seed:42},{prompt:'',seed:'same'}).seed,42)
})
test('malformed settings reject visibly instead of being silently discarded', () => {
for (const raw of ['bad JSON', {}, [null], [{steps:51}], [{steps:1.5}], [{cfg:-1}], [{seed:-1}], [{strength:2}], [{loraStack:[{name:'x',strengthModel:3}]}], Array(50).fill({})]) {
assert.throws(() => normalize(raw))
}
assert.throws(() => sweep('24, potato', '1.5'))
assert.throws(() => sweep('', '1.5'))
})
test('iteration runner sends per-image settings to Comfy and saves matching metadata', async () => {
const built = [], saved = []
const job = {id:'test', clientId:'client', status:'queued', progress:0, library:{ownerKey:'owner',prompt:'shared prompt',promptMid:'shared prompt',name:'test',folderId:'inbox'}}
const baseStack = [{ name:'base.safetensors', strengthModel:1, strengthClip:1 }]
const customStack = [{ name:'custom.safetensors', strengthModel:0.5, strengthClip:0.5 }]
const mocks = {
'~/utils/imageIterations': iterations,
'~/server/utils/jobs': {createJob:()=>job,emitJob:()=>{}},
'~/server/utils/comfyLifecycle': {ensureComfyReady:async()=>{}},
'~/server/utils/comfy': {assertImageScaleToTotalPixelsNode:async()=>{},comfyImagePrefix:()=> 'test',getComfyHost:()=> 'fake',uploadImage:async()=>({name:'input'}),queuePrompt:async()=>({prompt_id:'p'+built.length})},
'~/server/utils/imageComfy': {withImageComfyHost:async(_,fn)=>fn(),waitForImageEdit:async()=>({filename:'out.png'}),downloadEditedImage:async()=>Buffer.from('test'),purgeImageComfyArtifacts:async()=>{}},
'~/server/utils/imageWorkflowV2': {buildImageV2Workflow:params=>{built.push(params);return {graph:{},workflowFile:'fake'}},IMAGE_V2_NODE_LABELS:{},resolveKreaGenerateAssets:async()=>({})},
'~/server/utils/loras': {ensureComfyLoraNames:async()=>{},persistLoraFields:stack=>({loraStack:stack})},
'~/server/utils/resolution': {imageDimensions:()=>({width:1024,height:1024})},
'~/server/utils/watch': {emitChainJob:()=>{}},
'~/server/utils/library': {saveStill:async params=>{assert.equal(job.saving,true);assert.equal(job.library.chainContinuing,true);saved.push(params);return {id:'s'+saved.length,filename:'out.png'}},stillChainName:(_,i)=>'image'+i},
'~/utils/outputNames': {comfyJobPrefix:()=> 'test'},
'~/utils/promptParts': {restorePromptParts:prompt=>({prompt}),wrappedPromptForComfy:(_,prompt)=>prompt},
'~/utils/videoModels': {isXaigenStudio:()=>false},
'~/utils/imageV2': {imageV2GraphMode:()=> 'generate',imageV2StackSpecials:()=>({}),mergeLegacyImageV2Loras:stack=>stack || [],parseImageV2Engine:()=> 'flux'},
'~/server/utils/imagePad': {applyImageFill:async({image})=>({image,padded:false})},
'~/utils/imageAspect': {composeFillPrompt:prompt=>prompt},
'~/server/utils/studioQueue': {onLiveVideoSettled:async()=>{assert.equal(job.saving,false);assert.equal(job.library.chainContinuing,false)}}
}
const {runEditV2} = load('server/utils/imageChainV2.ts', mocks)
await runEditV2(job,{ mode:'iterate',task:'t2i',prompt:'shared prompt',negative:'',steps:24,cfg:1.5,seed:42,loraStack:baseStack,
snofsModel:0,snofsClip:0,consistencyModel:0,consistencyClip:0,megapixels:0,reference:null,
passes:[{prompt:'',steps:20,seed:'same'},{prompt:'alternate',steps:18,cfg:2,seed:0,loraStack:customStack},{prompt:'',steps:19,seed:'same',loraStack:[]}] })
assert.equal(job.status,'complete',job.error)
assert.deepEqual(built.map(p=>p.steps),[24,20,18,19])
assert.deepEqual(built.map(p=>p.cfg),[1.5,1.5,2,1.5])
assert.deepEqual(built.map(p=>p.seed),[42,42,0,42])
assert.deepEqual(built.map(p=>p.prompt),['shared prompt','shared prompt','alternate','shared prompt'])
assert.deepEqual(built.map(p=>p.loraStack),[baseStack,baseStack,customStack,[]])
assert.deepEqual(saved.map(p=>p.settings.steps),[24,20,18,19])
assert.deepEqual(saved.map(p=>p.settings.loraStack),[baseStack,baseStack,customStack,[]])
})
test('editing a queued batch preserves its per-variation settings', () => {
const queued = load('utils/queuedJob.ts', {
'~/utils/loras': {persistLoraFields:()=>({}),readLoraStack:()=>[]},
'~/utils/music': {}
})
const payload = {prompt:'shared',passes:[{prompt:'',steps:18,cfg:1.5,seed:'same',loraStack:[]}]}
const shots = queued.queuedJobShots(payload,'edit')
shots[1].steps = 19
const restored = queued.applyQueuedShots('edit',shots,payload)
assert.deepEqual(restored.passes,[{prompt:'',steps:19,cfg:1.5,seed:'same',loraStack:[]}])
assert.deepEqual(normalize(JSON.stringify(restored.passes)),restored.passes)
})
+79
View File
@@ -0,0 +1,79 @@
import type { LoraStackItem } from './loras'
/** An omitted field inherits the shared settings; an empty LoRA stack means none. */
export interface ImageIteration {
prompt: string
steps?: number
cfg?: number
seed?: number | 'same' | 'random'
strength?: number
loraStack?: LoraStackItem[]
}
export const MAX_IMAGE_ITERATIONS = 50
function optionalNumber(raw: unknown, label: string, min: number, max: number, integer = false) {
if (raw == null || raw === '') return undefined
const value = Number(raw)
if (!Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) {
throw new Error(`${label} must be ${integer ? 'a whole number ' : ''}between ${min} and ${max}.`)
}
return value
}
export function normalizeImageIterations(raw: unknown): ImageIteration[] {
if (raw == null || raw === '') return []
if (typeof raw === 'string') {
try { raw = JSON.parse(raw) } catch { throw new Error('Iterations must be a valid JSON list.') }
}
if (!Array.isArray(raw)) throw new Error('Iterations must be a list.')
if (raw.length >= MAX_IMAGE_ITERATIONS) throw new Error(`Use at most ${MAX_IMAGE_ITERATIONS} images including the first.`)
return raw.map((item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) throw new Error(`Iteration ${index + 2} is invalid.`)
const result: ImageIteration = { prompt: String(item.prompt ?? '').trim().slice(0, 8000) }
const steps = optionalNumber(item.steps, 'Steps', 1, 50, true)
const cfg = optionalNumber(item.cfg, 'CFG', 0, 20)
const strength = optionalNumber(item.strength, 'Denoise', 0.15, 0.75)
if (steps !== undefined) result.steps = steps
if (cfg !== undefined) result.cfg = Math.round(cfg * 10) / 10
if (strength !== undefined) result.strength = Math.round(strength * 100) / 100
if (item.seed === 'same' || item.seed === 'random') result.seed = item.seed
else {
const seed = optionalNumber(item.seed, 'Seed', 0, 2_147_483_647, true)
if (seed !== undefined) result.seed = seed
}
if (item.loraStack != null) {
if (!Array.isArray(item.loraStack) || item.loraStack.length > 8) throw new Error('Use at most 8 LoRAs per iteration.')
result.loraStack = item.loraStack.map((lora: Record<string, unknown>) => {
const name = String(lora?.name ?? '').trim()
if (!name) throw new Error('Each iteration LoRA needs a name.')
return { name,
strengthModel: optionalNumber(lora.strengthModel, 'LoRA model strength', -2, 2) ?? 1,
strengthClip: optionalNumber(lora.strengthClip, 'LoRA CLIP strength', -2, 2) ?? 1 }
})
}
return result
})
}
export function resolveImageIteration(base: { steps: number; cfg: number; seed: number; strength?: number }, pass?: ImageIteration, random = Math.random) {
return {
steps: pass?.steps ?? base.steps,
cfg: pass?.cfg ?? base.cfg,
strength: pass?.strength ?? base.strength,
seed: !pass || pass.seed === 'same' ? base.seed
: typeof pass.seed === 'number' ? pass.seed : Math.floor(random() * 2_147_483_647)
}
}
/** Ordered Cartesian product: each CFG value is rendered at each requested step count. */
export function imageIterationSweep(stepText: string, cfgText: string) {
const parse = (text: string) => text.split(/[,\s]+/).filter(Boolean).map(Number)
const steps = parse(stepText)
const cfgs = parse(cfgText)
if (!steps.length || !cfgs.length) throw new Error('Enter at least one steps value and one CFG value.')
if (steps.length * cfgs.length > MAX_IMAGE_ITERATIONS) throw new Error(`A sweep can contain at most ${MAX_IMAGE_ITERATIONS} images.`)
const rows = cfgs.flatMap(cfg => steps.map(steps => ({ prompt: '', steps, cfg, seed: 'same' as const })))
// Validate the first row too; the normal parser reserves one slot for the shared first image.
return rows.map(row => normalizeImageIterations([row])[0])
}
+5 -4
View File
@@ -14,7 +14,7 @@ export const LOAD_GENERATION_PAYLOAD_KEY = 'aigen-load-generation-payload'
export type QueuedInspectKind = 'video' | 'edit' | 'music'
export type QueuedInspectSource = 'studio' | 'live'
export type QueuedShotDraft = {
export type QueuedShotDraft = import('~/utils/imageIterations').ImageIteration & {
prompt: string
duration: number
loraName?: string
@@ -48,7 +48,8 @@ export type QueuedInspectPayload = {
loraName?: string
loraStack?: LoraStackItem[]
negative?: string
passes?: { prompt: string }[]
passes?: import('~/utils/imageIterations').ImageIteration[]
passMode?: 'batch' | 'chain'
imagePipeline?: 'v1' | 'v2'
v2Mode?: string
v2Task?: string
@@ -99,7 +100,7 @@ export function queuedJobEditable(job: { source?: string; status?: string }) {
export function queuedJobShots(payload: QueuedInspectPayload, kind: string): QueuedShotDraft[] {
if (kind === 'edit') {
const first = { prompt: payload.prompt || '', duration: 0 }
const rest = (payload.passes || []).map(item => ({ prompt: item.prompt || '', duration: 0 }))
const rest = (payload.passes || []).map(item => ({ ...item, prompt: item.prompt || '', duration: 0 }))
return rest.length ? [first, ...rest] : [first]
}
if (kind === 'music') return []
@@ -126,7 +127,7 @@ export function applyQueuedShots(kind: string, shots: QueuedShotDraft[], payload
}
if (kind === 'edit') {
next.prompt = shots[0]?.prompt || ''
next.passes = shots.slice(1).map(item => ({ prompt: item.prompt || '' }))
next.passes = shots.slice(1).map(({ duration, ...item }) => ({ ...item, prompt: item.prompt || '' }))
return next
}
next.prompt = shots[0]?.prompt || ''