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"> <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> <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" /> <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"> <label v-if="job.kind !== 'edit'" class="mt-2 block text-xs text-zinc-400">
Duration 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"> <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> </label>
</div> </div>
<p v-if="studioMode === 'editv2' && v2Mode === 'generate'" class="mt-3 text-xs text-zinc-500">No reference image. Prompt only.</p> <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"> <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> <p class="text-xs text-zinc-500">Prompt only what should change in the painted area.</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -380,6 +380,7 @@
</div> </div>
</div> </div>
<div v-if="studioMode === 'edit' || v2PassChaining" class="mt-3 space-y-3"> <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"> <div v-if="!(v2Mode === 'iterate' && !file)" class="grid grid-cols-2 gap-2">
<button <button
type="button" type="button"
@@ -435,6 +436,16 @@
:keep-items="activeKeepPrompts" :keep-items="activeKeepPrompts"
:keep-heading="keepPromptHeading" :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> </div>
<button <button
type="button" type="button"
@@ -2242,6 +2253,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { normalizeImageIterations, type ImageIteration } from '~/utils/imageIterations'
import { composePromptParts, formatPromptParts, resolvePromptWrappers, restorePromptParts } from '~/utils/promptParts' import { composePromptParts, formatPromptParts, resolvePromptWrappers, restorePromptParts } from '~/utils/promptParts'
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend' import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
import { parseImageBatchScript, parseShotScript } from '~/utils/parseShots' import { parseImageBatchScript, parseShotScript } from '~/utils/parseShots'
@@ -2496,7 +2508,7 @@ interface QueuedExtension {
loraStack?: LoraStackItem[] loraStack?: LoraStackItem[]
} }
interface QueuedEditPass { interface QueuedEditPass extends ImageIteration {
id: string id: string
prompt: string prompt: string
} }
@@ -3213,7 +3225,7 @@ const editV2Blocked = computed(() => {
if (v2Mode.value !== 'generate' && v2Mode.value !== 'iterate' && !file.value) return true if (v2Mode.value !== 'generate' && v2Mode.value !== 'iterate' && !file.value) return true
if (v2Mode.value === 'compose' && !editRefFile.value) return true if (v2Mode.value === 'compose' && !editRefFile.value) return true
if (v2Mode.value === 'refine' && !refineMaskDirty.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 if (v2Mode.value === 'iterate' && !editPassQueue.value.length) return true
return false return false
}) })
@@ -3226,8 +3238,8 @@ const editV2BlockReason = computed(() => {
? 'Write a prompt first.' ? 'Write a prompt first.'
: 'Write a prompt, or pick Landscape / Portrait / Square to fill the frame.' : '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 (v2Mode.value === 'iterate' && !editPassQueue.value.length) return 'Add a variation or build a steps/CFG sweep.'
if (v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) return 'Each extra pass needs a prompt.' 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 (!folderId.value) return 'Choose a library folder before generating.'
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image v2 is not configured. Set COMFY_HOST.' if (!imageComfyConfigured.value && !comfyOk.value) return 'Image v2 is not configured. Set COMFY_HOST.'
return '' 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.' return 'Each extra pass is another Edit on the last output. You cannot switch graphs between passes.'
}) })
const extraPassPlaceholder = computed(() => { 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…' if (imagePassMode.value === 'batch') return 'Another prompt for the same start still…'
return studioMode.value === 'editv2' && v2Mode.value === 'compose' return studioMode.value === 'editv2' && v2Mode.value === 'compose'
? 'Next compose prompt, applied to the previous pass output…' ? 'Next compose prompt, applied to the previous pass output…'
@@ -5481,6 +5493,9 @@ async function restoreStudioEditJob(payload: {
cfg?: number cfg?: number
turbo?: boolean turbo?: boolean
referenceStillId?: string referenceStillId?: string
passes?: ImageIteration[]
passMode?: ImagePassMode
seed?: number
}) { }) {
closeExtend() closeExtend()
applyPromptRestore(payload.prompt || '', payload) applyPromptRestore(payload.prompt || '', payload)
@@ -5489,6 +5504,10 @@ async function restoreStudioEditJob(payload: {
folderId.value = payload.folderId folderId.value = payload.folderId
browseFolderId.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' const v2 = payload.imagePipeline === 'v2'
if (v2) { if (v2) {
studioMode.value = 'editv2' studioMode.value = 'editv2'
@@ -5612,6 +5631,8 @@ async function restoreStudioPayload(kind: string, payload: {
scaleMegapixels?: number scaleMegapixels?: number
steps?: number steps?: number
referenceStillId?: string referenceStillId?: string
passes?: ImageIteration[]
passMode?: ImagePassMode
}) { }) {
if (kind === 'edit' || payload.imagePipeline === 'v2') { if (kind === 'edit' || payload.imagePipeline === 'v2') {
await restoreStudioEditJob(payload) await restoreStudioEditJob(payload)
@@ -6965,6 +6986,18 @@ function removeQueuedExtension(id: string) {
extensionQueue.value = extensionQueue.value.filter(item => item.id !== id) 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() { function queueEditPass() {
editPassQueue.value.push({ editPassQueue.value.push({
id: crypto.randomUUID(), 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.') toast('Paint a mask on Still A first. Refine will not fall back to Edit.')
return 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.') toast('Each extra pass needs a prompt.')
return return
} }
absorbPastedImageBatch() 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 const hideOut = hideThumbnail.value
try { try {
await ensureGenerationNotifyPermission() 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 { persistLoraFields, normalizeLoraStack } from '~/utils/loras'
import { listStudioJobs, patchStudioJob, type StudioJobPayload } from '~/server/utils/studioQueue' 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 body = await readBody<Record<string, unknown>>(event).catch(() => ({}))
const job = await patchStudioJob(owner, id, (row) => { 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 const payload = row.payload
if (body.name != null) { if (body.name != null) {
const name = text(body.name, 80).trim() const name = text(body.name, 80).trim()
@@ -75,9 +78,16 @@ export default defineEventHandler(async (event) => {
payload.loraStack = fields.loraStack payload.loraStack = fields.loraStack
} }
if (Array.isArray(body.passes) && row.kind === 'edit') { if (Array.isArray(body.passes) && row.kind === 'edit') {
payload.passes = body.passes.map((item) => ({ try {
prompt: text((item as { prompt?: unknown })?.prompt) 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 row.shotCount = 1 + payload.passes.length
} }
if (Array.isArray(body.extensions) && row.kind !== 'edit') { 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 { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
import { comfyConfigured } from '~/server/utils/comfy' import { comfyConfigured } from '~/server/utils/comfy'
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect' import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
@@ -35,15 +36,9 @@ import { existsSync, readFileSync } from 'node:fs'
type ImageFile = { filename: string; data: Buffer; type?: string } type ImageFile = { filename: string; data: Buffer; type?: string }
function parsePasses(raw: unknown) { function parsePasses(raw: unknown) {
if (!raw) return [] as { prompt: string }[] try { return normalizeImageIterations(raw) } catch (error) {
let parsed = raw throw createError({ statusCode: 400, statusMessage: error instanceof Error ? error.message : String(error) })
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) { function parseBool(raw: unknown) {
@@ -157,10 +152,10 @@ export default defineEventHandler(async (event) => {
if (mode === 'iterate' && !extraPasses.length) { if (mode === 'iterate' && !extraPasses.length) {
throw createError({ throw createError({
statusCode: 400, 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' }) throw createError({ statusCode: 400, statusMessage: 'Each extra pass needs a prompt' })
} }
if (!comfyConfigured()) { if (!comfyConfigured()) {
@@ -278,6 +273,9 @@ export default defineEventHandler(async (event) => {
const generateSnofsClipFallback = 0 const generateSnofsClipFallback = 0
await listStudioLoras() await listStudioLoras()
const loraStack = assertImageV2LoraStack(parsePostedLoraStack(fields.loraStack || fields.lora, 'image'), engine) 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 stackSpecials = imageV2StackSpecials(loraStack, engine)
const snofsModel = conceptAllowed const snofsModel = conceptAllowed
? (stackSpecials.snofs?.strengthModel ?? clampImageV2Strength(fields.snofs_model, generateSnofsFallback)) ? (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 { createJob, emitJob, type Job } from '~/server/utils/jobs'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle' import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import { assertImageScaleToTotalPixelsNode, comfyImagePrefix, getComfyHost, uploadImage, queuePrompt } from '~/server/utils/comfy' import { assertImageScaleToTotalPixelsNode, comfyImagePrefix, getComfyHost, uploadImage, queuePrompt } from '~/server/utils/comfy'
@@ -46,7 +47,7 @@ export type EditV2RunParams = {
sourceStillId?: string sourceStillId?: string
referenceStillId?: string referenceStillId?: string
loraStack?: import('~/utils/loras').LoraStackItem[] loraStack?: import('~/utils/loras').LoraStackItem[]
passes?: { prompt: string }[] passes?: import('~/utils/imageIterations').ImageIteration[]
passMode?: 'batch' | 'chain' 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 canChain = params.mode === 'iterate' || params.mode === 'edit' || params.mode === 'compose'
const extraPasses = canChain const extraPasses = canChain
? (params.passes || []).map(item => ({ prompt: String(item.prompt || '').trim() })).filter(item => item.prompt) ? normalizeImageIterations(params.passes)
: [] : []
const prompts = [ const prompts = [
library.promptMid || restorePromptParts(params.prompt, { library.promptMid || restorePromptParts(params.prompt, {
@@ -80,7 +81,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
prompt: params.prompt, prompt: params.prompt,
post: library.promptPost post: library.promptPost
}).prompt, }).prompt,
...extraPasses.map(item => item.prompt) ...extraPasses.map(item => item.prompt || library.promptMid || library.prompt || params.prompt)
] ]
const chainTotal = prompts.length const chainTotal = prompts.length
// Text-only iterate is always independent generations. With a still, honor batch vs chain. // 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 library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
try { try {
// Keep the studio slot across all iterations, including upload/save gaps.
library.chainContinuing = true
await ensureComfyReady((status) => { await ensureComfyReady((status) => {
emitChainJob(job, { emitChainJob(job, {
type: status.state === 'busy' ? 'busy' : 'status', type: status.state === 'busy' ? 'busy' : 'status',
@@ -117,22 +120,6 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
await ensureComfyLoraNames('image') await ensureComfyLoraNames('image')
if (!generate && params.megapixels > 0) await assertImageScaleToTotalPixelsNode() if (!generate && params.megapixels > 0) await assertImageScaleToTotalPixelsNode()
const krea = engine === 'krea' ? await resolveKreaGenerateAssets() : null 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) { if (!generate && graphMode === 'compose' && params.reference) {
uploadedRef = await uploadImage({ uploadedRef = await uploadImage({
...params.reference, ...params.reference,
@@ -144,7 +131,28 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
if (job.status === 'cancelled') throw new Error('Job interrupted.') if (job.status === 'cancelled') throw new Error('Job interrupted.')
if (index > 0 && library.stopAfterCurrent === true) break if (index > 0 && library.stopAfterCurrent === true) break
const last = index === prompts.length - 1 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.prompt = prompts[index]
library.seed = seed library.seed = seed
library.chainIndex = index library.chainIndex = index
@@ -238,19 +246,19 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
imageAName: uploaded?.name, imageAName: uploaded?.name,
imageBName: uploadedRef?.name, imageBName: uploadedRef?.name,
maskName: uploadedMask?.name, maskName: uploadedMask?.name,
strength: params.strength, strength,
snofsModel, snofsModel,
snofsClip, snofsClip,
consistencyModel, consistencyModel,
consistencyClip, consistencyClip,
steps: params.steps, steps,
cfg: params.cfg, cfg,
seed, seed,
megapixels: params.megapixels, megapixels: params.megapixels,
width: params.width, width: params.width,
height: params.height, height: params.height,
aspect: params.aspect, aspect: params.aspect,
turbo: params.turbo === true, turbo,
filenamePrefix: chainTotal > 1 ? `${comfyImagePrefix()}_${comfyJobPrefix(job.id)}_p${index + 1}` : `${comfyImagePrefix()}_${comfyJobPrefix(job.id)}`, filenamePrefix: chainTotal > 1 ? `${comfyImagePrefix()}_${comfyJobPrefix(job.id)}_p${index + 1}` : `${comfyImagePrefix()}_${comfyJobPrefix(job.id)}`,
kreaUnetName: krea?.unet, kreaUnetName: krea?.unet,
kreaClipName: krea?.clip, kreaClipName: krea?.clip,
@@ -265,7 +273,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
type: 'status', type: 'status',
message: `Running ${built.workflowFile}...`, message: `Running ${built.workflowFile}...`,
progress: 18, progress: 18,
maxStep: params.steps maxStep: steps
}) })
const output = await waitForImageEdit({ const output = await waitForImageEdit({
@@ -283,7 +291,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
message: event.message, message: event.message,
progress: event.progress, progress: event.progress,
step: event.step, step: event.step,
maxStep: event.maxStep || params.steps, maxStep: event.maxStep || steps,
node: event.node 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 }) emitChainJob(job, { type: 'status', message: `Saving ${engineLabel} v2 still...`, progress: 94 })
job.saving = true
const buffer = await downloadEditedImage(output) const buffer = await downloadEditedImage(output)
const size = imageDimensions(buffer) const size = imageDimensions(buffer)
const still = await saveStill({ const still = await saveStill({
@@ -319,12 +328,12 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
snofsClip, snofsClip,
consistencyModel, consistencyModel,
consistencyClip, consistencyClip,
steps: params.steps, steps,
cfg: params.cfg, cfg,
megapixels: generate || !(params.megapixels > 0) ? undefined : params.megapixels, megapixels: generate || !(params.megapixels > 0) ? undefined : params.megapixels,
scaleToTotalPixels: generate ? undefined : params.megapixels > 0, scaleToTotalPixels: generate ? undefined : params.megapixels > 0,
turbo: params.turbo === true, turbo,
strength: generate ? undefined : params.strength, strength: generate ? undefined : strength,
width: generate ? params.width : undefined, width: generate ? params.width : undefined,
height: generate ? params.height : undefined, height: generate ? params.height : undefined,
aspect: params.aspect || 'auto', aspect: params.aspect || 'auto',
@@ -346,6 +355,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
promptId: job.promptId promptId: job.promptId
}) })
job.saving = false
if (!last) { if (!last) {
emitChainJob(job, { emitChainJob(job, {
type: 'checkpoint', type: 'checkpoint',
@@ -390,6 +400,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
emitJob(job, { type: 'error', error: message, message }) emitJob(job, { type: 'error', error: message, message })
} }
} finally { } finally {
job.saving = false
library.chainContinuing = false
const { onLiveVideoSettled } = await import('~/server/utils/studioQueue') const { onLiveVideoSettled } = await import('~/server/utils/studioQueue')
await onLiveVideoSettled(job) await onLiveVideoSettled(job)
} }
+1 -1
View File
@@ -96,7 +96,7 @@ export interface Job {
parentStillId?: string parentStillId?: string
workflow?: import('~/utils/videoModels').VideoWorkflowId workflow?: import('~/utils/videoModels').VideoWorkflowId
chainContinuing?: boolean chainContinuing?: boolean
passes?: { prompt: string }[] passes?: import('~/utils/imageIterations').ImageIteration[]
queueId?: string queueId?: string
queueAutoRun?: boolean queueAutoRun?: boolean
queueBudget?: number queueBudget?: number
+3 -3
View File
@@ -52,7 +52,7 @@ export interface StudioJobPayload {
shotLoras?: string[] shotLoras?: string[]
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][] shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
negative?: string negative?: string
passes?: { prompt: string }[] passes?: import('~/utils/imageIterations').ImageIteration[]
passMode?: 'batch' | 'chain' passMode?: 'batch' | 'chain'
referenceStillId?: string referenceStillId?: string
referenceStillFilename?: string referenceStillFilename?: string
@@ -1207,7 +1207,7 @@ async function startStudioEditJob(item: StudioJob) {
height: payload.height, height: payload.height,
steps: payload.steps, steps: payload.steps,
turbo: payload.turbo === true, 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, cfg: payload.cfg,
stillId: payload.stillId, stillId: payload.stillId,
stillFilename: payload.stillFilename, stillFilename: payload.stillFilename,
@@ -1235,7 +1235,7 @@ async function startStudioEditJob(item: StudioJob) {
prompt: payload.prompt, prompt: payload.prompt,
negative: payload.negative || '', negative: payload.negative || '',
steps: payload.steps, 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, cfg: payload.cfg,
snofsModel: v2Specials.snofs?.strengthModel ?? payload.snofsModel ?? 0, snofsModel: v2Specials.snofs?.strengthModel ?? payload.snofsModel ?? 0,
snofsClip: v2Specials.snofs?.strengthClip ?? payload.snofsClip ?? 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 QueuedInspectKind = 'video' | 'edit' | 'music'
export type QueuedInspectSource = 'studio' | 'live' export type QueuedInspectSource = 'studio' | 'live'
export type QueuedShotDraft = { export type QueuedShotDraft = import('~/utils/imageIterations').ImageIteration & {
prompt: string prompt: string
duration: number duration: number
loraName?: string loraName?: string
@@ -48,7 +48,8 @@ export type QueuedInspectPayload = {
loraName?: string loraName?: string
loraStack?: LoraStackItem[] loraStack?: LoraStackItem[]
negative?: string negative?: string
passes?: { prompt: string }[] passes?: import('~/utils/imageIterations').ImageIteration[]
passMode?: 'batch' | 'chain'
imagePipeline?: 'v1' | 'v2' imagePipeline?: 'v1' | 'v2'
v2Mode?: string v2Mode?: string
v2Task?: string v2Task?: string
@@ -99,7 +100,7 @@ export function queuedJobEditable(job: { source?: string; status?: string }) {
export function queuedJobShots(payload: QueuedInspectPayload, kind: string): QueuedShotDraft[] { export function queuedJobShots(payload: QueuedInspectPayload, kind: string): QueuedShotDraft[] {
if (kind === 'edit') { if (kind === 'edit') {
const first = { prompt: payload.prompt || '', duration: 0 } 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] return rest.length ? [first, ...rest] : [first]
} }
if (kind === 'music') return [] if (kind === 'music') return []
@@ -126,7 +127,7 @@ export function applyQueuedShots(kind: string, shots: QueuedShotDraft[], payload
} }
if (kind === 'edit') { if (kind === 'edit') {
next.prompt = shots[0]?.prompt || '' 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 return next
} }
next.prompt = shots[0]?.prompt || '' next.prompt = shots[0]?.prompt || ''