Add per-image iteration settings and steps/CFG sweeps
This commit is contained in:
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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
@@ -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 || ''
|
||||||
|
|||||||
Reference in New Issue
Block a user