Add Image Iterate: nested multi-prompt still families.

Iterate runs a series of prompts as generate (no still), edit (still A), or compose (A+B), saves each output under one library collection like video extensions, and defaults to iterating from the original still.
This commit is contained in:
2026-09-04 22:46:19 -05:00
parent 279ac72049
commit 2435167a41
6 changed files with 261 additions and 130 deletions
+120 -38
View File
@@ -24,6 +24,12 @@
<p class="text-sm text-zinc-400">{{ studioMode === 'editv2' <p class="text-sm text-zinc-400">{{ studioMode === 'editv2'
? (v2Mode === 'generate' ? (v2Mode === 'generate'
? 'Generate: no reference image. Prompt only.' ? 'Generate: no reference image. Prompt only.'
: v2Mode === 'iterate'
? (file
? (editRefFile
? 'Iterate: each prompt runs as Compose from stills A and B (or chain from the last output). All results nest in one library collection.'
: 'Iterate: each prompt runs from still A (batch) or from the last output (chain). All results nest in one library collection.')
: 'Iterate: each prompt is a separate generate. All results nest in one library collection like video extensions.')
: v2Mode === 'refine' : v2Mode === 'refine'
? 'Refine: paint a mask on Still A. Prompt only what should change in the painted area. Strength is denoise in that region.' ? 'Refine: paint a mask on Still A. Prompt only what should change in the painted area. Strength is denoise in that region.'
: v2Mode === 'compose' : v2Mode === 'compose'
@@ -35,7 +41,7 @@
</div> </div>
<div <div
v-if="!(studioMode === 'editv2' && v2Mode === 'generate')" v-if="!(studioMode === 'editv2' && (v2Mode === 'generate' || (v2Mode === 'iterate' && !file && !inputStillId)))"
class="relative cursor-pointer rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 p-4 transition hover:border-amber-300/50" class="relative cursor-pointer rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 p-4 transition hover:border-amber-300/50"
:class="{ 'border-amber-300/70 bg-amber-400/5': dragging }" :class="{ 'border-amber-300/70 bg-amber-400/5': dragging }"
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@@ -71,7 +77,14 @@
v-else-if="studioMode === 'editv2' && v2Mode === 'generate' && (file || inputStillId)" v-else-if="studioMode === 'editv2' && v2Mode === 'generate' && (file || inputStillId)"
class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm text-zinc-400" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm text-zinc-400"
> >
Still A is loaded for Edit / Compose / Refine. Generate ignores it and uses the prompt only. Still A is loaded for Edit / Compose / Refine / Iterate. Generate ignores it and uses the prompt only.
</div>
<div
v-else-if="studioMode === 'editv2' && v2Mode === 'iterate'"
class="rounded-2xl border border-dashed border-white/15 bg-zinc-950/40 px-4 py-3 text-sm text-zinc-400"
>
<button type="button" class="font-medium text-amber-100 hover:underline" @click="openPicker('main')">Optional · add still A</button>
<span class="mt-1 block text-zinc-500">No still = text generate iterations. With a still = edit (or compose if you add still B).</span>
</div> </div>
<div v-if="file || inputStillId" class="mt-2 flex min-w-0 items-center gap-2"> <div v-if="file || inputStillId" class="mt-2 flex min-w-0 items-center gap-2">
@@ -144,7 +157,17 @@
> >
Generate Generate
</button> </button>
<label v-if="v2Mode === 'compose'" class="ml-auto text-xs"> <button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-40"
:class="v2Mode === 'iterate' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
:disabled="v2ModeLocked && v2Mode !== 'iterate'"
:title="v2ModeLocked && v2Mode !== 'iterate' ? 'Remove extra passes to change graphs' : ''"
@click="setV2Mode('iterate')"
>
Iterate
</button>
<label v-if="v2Mode === 'compose' || (v2Mode === 'iterate' && editRefFile)" class="ml-auto text-xs">
<span class="mr-2 text-zinc-500">Task</span> <span class="mr-2 text-zinc-500">Task</span>
<select <select
v-model="v2Task" v-model="v2Task"
@@ -158,9 +181,9 @@
</label> </label>
</div> </div>
<div v-if="studioMode === 'edit' || (studioMode === 'editv2' && v2Mode === 'compose')" class="rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 p-4"> <div v-if="studioMode === 'edit' || (studioMode === 'editv2' && (v2Mode === 'compose' || (v2Mode === 'iterate' && file)))" class="rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 p-4">
<div class="mb-2 flex items-center justify-between gap-2"> <div class="mb-2 flex items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' ? 'Still B · required' : 'Image 2 · optional' }}</p> <p class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' && v2Mode === 'compose' ? 'Still B · required' : studioMode === 'editv2' ? 'Still B · optional' : 'Image 2 · optional' }}</p>
<button <button
v-if="editRefFile" v-if="editRefFile"
type="button" type="button"
@@ -203,7 +226,7 @@
<div> <div>
<div class="mb-2 flex flex-wrap items-center justify-between gap-2"> <div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<label class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' && v2Mode === 'generate' ? 'Generate prompt' : studioMode === 'editv2' && v2Mode === 'refine' ? 'Refine prompt' : studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : 'Motion & scene prompt' }}</label> <label class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' && v2Mode === 'generate' ? 'Generate prompt' : studioMode === 'editv2' && v2Mode === 'iterate' ? 'Iteration 1' : studioMode === 'editv2' && v2Mode === 'refine' ? 'Refine prompt' : studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<button <button
v-if="identityPrompting" v-if="identityPrompting"
@@ -292,6 +315,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>
<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">
@@ -345,14 +369,14 @@
</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">
<div 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"
class="rounded-xl border px-3 py-2 text-left text-sm transition" class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="imagePassMode === 'batch' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'" :class="imagePassMode === 'batch' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="imagePassMode = 'batch'" @click="imagePassMode = 'batch'"
> >
<span class="block font-semibold">Batch</span> <span class="block font-semibold">{{ v2Mode === 'iterate' ? 'From original' : 'Batch' }}</span>
<span class="text-[11px] leading-snug text-zinc-400">Each prompt from the start still</span> <span class="text-[11px] leading-snug text-zinc-400">Each prompt from the start still</span>
</button> </button>
<button <button
@@ -361,7 +385,7 @@
:class="imagePassMode === 'chain' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'" :class="imagePassMode === 'chain' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="imagePassMode = 'chain'" @click="imagePassMode = 'chain'"
> >
<span class="block font-semibold">Chain</span> <span class="block font-semibold">{{ v2Mode === 'iterate' ? 'From last' : 'Chain' }}</span>
<span class="text-[11px] leading-snug text-zinc-400">Each pass from the last output</span> <span class="text-[11px] leading-snug text-zinc-400">Each pass from the last output</span>
</button> </button>
</div> </div>
@@ -383,7 +407,7 @@
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4" class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
> >
<div class="mb-3 flex items-center justify-between gap-2"> <div class="mb-3 flex items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-200">{{ imagePassMode === 'batch' ? 'Prompt' : 'Pass' }} {{ index + 2 }}</p> <p class="text-sm font-medium text-zinc-200">{{ v2Mode === 'iterate' ? 'Iteration' : imagePassMode === 'batch' ? 'Prompt' : 'Pass' }} {{ index + 2 }}</p>
<button <button
type="button" type="button"
class="text-xs text-red-300 hover:text-red-200" class="text-xs text-red-300 hover:text-red-200"
@@ -844,7 +868,7 @@
compact compact
:disabled="v2Turbo" :disabled="v2Turbo"
/> />
<template v-if="v2Mode !== 'generate'"> <template v-if="v2Mode !== 'generate' && !(v2Mode === 'iterate' && !file)">
<LampToggle :model-value="v2ScaleOn" label="Scale to MP" @update:model-value="setV2ScaleOn" /> <LampToggle :model-value="v2ScaleOn" label="Scale to MP" @update:model-value="setV2ScaleOn" />
<Thumbwheel <Thumbwheel
v-model="v2Megapixels" v-model="v2Megapixels"
@@ -1982,7 +2006,7 @@
<p class="truncate text-xs text-zinc-500">{{ stillMetaLine(entry.stillGroup.head) }}</p> <p class="truncate text-xs text-zinc-500">{{ stillMetaLine(entry.stillGroup.head) }}</p>
<div class="mt-2 flex flex-wrap items-center gap-3"> <div class="mt-2 flex flex-wrap items-center gap-3">
<button v-if="entry.stillGroup.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.stillGroup.key)"> <button v-if="entry.stillGroup.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.stillGroup.key)">
{{ expandedFamilies[entry.stillGroup.key] ? 'Collapse' : `Expand ${entry.stillGroup.parts.length} passes` }} {{ expandedFamilies[entry.stillGroup.key] ? 'Collapse' : `Expand ${entry.stillGroup.parts.length} ${entry.stillGroup.head.settings?.mode === 'iterate' ? 'iterations' : 'passes'}` }}
</button> </button>
<button type="button" class="text-xs text-amber-300" @click="openStill(entry.stillGroup.head)">Open</button> <button type="button" class="text-xs text-amber-300" @click="openStill(entry.stillGroup.head)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(entry.stillGroup.head)">Use as input</button> <button type="button" class="text-xs text-amber-300" @click="useStillAsInput(entry.stillGroup.head)">Use as input</button>
@@ -3198,38 +3222,52 @@ const stillBHint = computed(() => {
const editV2Blocked = computed(() => { const editV2Blocked = computed(() => {
if (!folderId.value) return true if (!folderId.value) return true
if (!prompt.value.trim() && !imageFillWithoutPrompt.value) return true if (!prompt.value.trim() && !imageFillWithoutPrompt.value) return true
if (v2Mode.value !== 'generate' && !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 (v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) return true
if (v2Mode.value === 'iterate' && !editPassQueue.value.length) return true
return false return false
}) })
const editV2BlockReason = computed(() => { const editV2BlockReason = computed(() => {
if (v2Mode.value !== 'generate' && !file.value) return 'Load still A first.' if (v2Mode.value !== 'generate' && v2Mode.value !== 'iterate' && !file.value) return 'Load still A first.'
if (v2Mode.value === 'compose' && !editRefFile.value) return 'Compose requires still B. This will not fall back to one-image generation.' if (v2Mode.value === 'compose' && !editRefFile.value) return 'Compose requires still B. This will not fall back to one-image generation.'
if (v2Mode.value === 'refine' && !refineMaskDirty.value) return 'Paint a mask on Still A first. Refine will not fall back to Edit.' if (v2Mode.value === 'refine' && !refineMaskDirty.value) return 'Paint a mask on Still A first. Refine will not fall back to Edit.'
if (!prompt.value.trim() && !imageFillWithoutPrompt.value) { if (!prompt.value.trim() && !imageFillWithoutPrompt.value) {
return v2Mode.value === 'generate' return v2Mode.value === 'generate' || (v2Mode.value === 'iterate' && !file.value)
? '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 (v2PassChaining.value && editPassQueue.value.some(item => !item.prompt.trim())) return 'Each extra pass needs a prompt.' if (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 ''
}) })
const v2PassChaining = computed(() => studioMode.value === 'editv2' && (v2Mode.value === 'edit' || v2Mode.value === 'compose')) const v2PassChaining = computed(() => studioMode.value === 'editv2' && (v2Mode.value === 'edit' || v2Mode.value === 'compose' || v2Mode.value === 'iterate'))
const v2ModeLocked = computed(() => v2PassChaining.value && editPassQueue.value.length > 0) const v2ModeLocked = computed(() => v2PassChaining.value && editPassQueue.value.length > 0)
const extraPassButtonLabel = computed(() => { const extraPassButtonLabel = computed(() => {
if (v2Mode.value === 'iterate') return '+ Iteration'
if (imagePassMode.value === 'batch') return '+ Prompt' if (imagePassMode.value === 'batch') return '+ Prompt'
return studioMode.value === 'editv2' && v2Mode.value === 'compose' ? '+ Compose pass' : '+ Edit pass' return studioMode.value === 'editv2' && v2Mode.value === 'compose' ? '+ Compose pass' : '+ Edit pass'
}) })
const imageBatchButtonHint = computed(() => { const imageBatchButtonHint = computed(() => {
const n = 1 + editPassQueue.value.length const n = 1 + editPassQueue.value.length
if (imageBatchScript.value.trim() && n > 1) return `${n} prompts on the start still. Same format as a video shot list.` if (imageBatchScript.value.trim() && n > 1) {
return 'Paste shot 1, shot 2, shot 3. Each image is a new prompt on the original still.' return v2Mode.value === 'iterate'
? `${n} iterations. Same format as a video shot list.`
: `${n} prompts on the start still. Same format as a video shot list.`
}
return v2Mode.value === 'iterate'
? 'Paste shot 1, shot 2, shot 3. Each line becomes one nested still.'
: 'Paste shot 1, shot 2, shot 3. Each image is a new prompt on the original still.'
}) })
const extraPassHint = computed(() => { const extraPassHint = computed(() => {
if (v2Mode.value === 'iterate') {
if (!file.value) return 'Each prompt is a separate generate. Outputs nest under one library collection.'
if (imagePassMode.value === 'batch') return 'Every prompt runs from the start still. Outputs nest under one library collection.'
return 'Each prompt runs from the last output. Outputs nest under one library collection.'
}
if (imagePassMode.value === 'batch') { if (imagePassMode.value === 'batch') {
return 'Every prompt runs from the start still. Paste a shot list, or add prompts one at a time. Outputs do not feed the next prompt.' return 'Every prompt runs from the start still. Paste a shot list, or add prompts one at a time. Outputs do not feed the next prompt.'
} }
@@ -3239,6 +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 (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…'
@@ -3247,14 +3286,25 @@ const extraPassPlaceholder = computed(() => {
const editV2SubmitLabel = computed(() => { const editV2SubmitLabel = computed(() => {
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running') const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
const n = v2PassChaining.value ? editPassQueue.value.length : 0 const n = v2PassChaining.value ? editPassQueue.value.length : 0
const label = v2Mode.value === 'generate' ? 'Generate image' : v2Mode.value === 'refine' ? 'Refine image' : v2Mode.value === 'compose' ? 'Compose image' : 'Edit image' const label = v2Mode.value === 'generate'
? 'Generate image'
: v2Mode.value === 'iterate'
? 'Iterate'
: v2Mode.value === 'refine'
? 'Refine image'
: v2Mode.value === 'compose'
? 'Compose image'
: 'Edit image'
if (!occupied) { if (!occupied) {
if (v2Mode.value === 'iterate') return n ? `Iterate × ${n + 1}` : 'Iterate'
if (!n) return label if (!n) return label
if (imagePassMode.value === 'batch') return n === 1 ? `${label} × 2` : `${label} × ${n + 1}` if (imagePassMode.value === 'batch') return n === 1 ? `${label} × 2` : `${label} × ${n + 1}`
return n === 1 ? `${label} + 1 pass` : `${label} + ${n} passes` return n === 1 ? `${label} + 1 pass` : `${label} + ${n} passes`
} }
if (n) { if (n) {
return imagePassMode.value === 'batch' ? `Queue ${n + 1} from start still` : `Queue ${n + 1} passes` return v2Mode.value === 'iterate'
? `Queue ${n + 1} iterations`
: imagePassMode.value === 'batch' ? `Queue ${n + 1} from start still` : `Queue ${n + 1} passes`
} }
return `Queue ${label.toLowerCase()}` return `Queue ${label.toLowerCase()}`
}) })
@@ -3646,7 +3696,8 @@ function stillFamilyScript(parts: LibraryStill[]) {
function stillMetaLine(still: LibraryStill) { function stillMetaLine(still: LibraryStill) {
const res = still.width && still.height ? `${still.width}×${still.height}` : 'image' const res = still.width && still.height ? `${still.width}×${still.height}` : 'image'
const mode = stillModeLabel(still) const mode = stillModeLabel(still)
const pass = typeof still.chainIndex === 'number' ? `pass ${still.chainIndex + 1}` : '' const unit = still.settings?.mode === 'iterate' ? 'iteration' : 'pass'
const pass = typeof still.chainIndex === 'number' ? `${unit} ${still.chainIndex + 1}` : ''
return [res, mode, pass].filter(Boolean).join(' · ') return [res, mode, pass].filter(Boolean).join(' · ')
} }
@@ -4618,7 +4669,15 @@ function applyGenerationPreset(preset: GenerationPreset) {
if (preset.kind === 'imagev2') { if (preset.kind === 'imagev2') {
const settings = preset.settings as ImageV2PresetSettings const settings = preset.settings as ImageV2PresetSettings
persistImageV2LoraStack(imageV2LoraStack.value, v2Engine.value) persistImageV2LoraStack(imageV2LoraStack.value, v2Engine.value)
v2Mode.value = settings.mode === 'compose' ? 'compose' : settings.mode === 'refine' ? 'refine' : settings.mode === 'generate' ? 'generate' : 'edit' v2Mode.value = settings.mode === 'compose'
? 'compose'
: settings.mode === 'refine'
? 'refine'
: settings.mode === 'generate'
? 'generate'
: settings.mode === 'iterate'
? 'iterate'
: 'edit'
if (v2Mode.value !== 'edit' && v2Mode.value !== 'compose') clearEditPasses() if (v2Mode.value !== 'edit' && v2Mode.value !== 'compose') clearEditPasses()
v2Engine.value = parseImageV2Engine(settings.engine, 'flux') v2Engine.value = parseImageV2Engine(settings.engine, 'flux')
v2Task.value = v2Mode.value === 'compose' ? (settings.task || 'scene') : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene' v2Task.value = v2Mode.value === 'compose' ? (settings.task || 'scene') : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene'
@@ -5419,7 +5478,7 @@ async function restoreStudioEditJob(payload: {
loraStack?: LoraStackItem[] loraStack?: LoraStackItem[]
negative?: string negative?: string
imagePipeline?: 'v1' | 'v2' imagePipeline?: 'v1' | 'v2'
v2Mode?: 'edit' | 'compose' | 'refine' | 'generate' v2Mode?: 'edit' | 'compose' | 'refine' | 'generate' | 'iterate'
v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i' v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i'
engine?: 'flux' | 'krea' engine?: 'flux' | 'krea'
snofsModel?: number snofsModel?: number
@@ -5445,7 +5504,7 @@ async function restoreStudioEditJob(payload: {
if (v2) { if (v2) {
studioMode.value = 'editv2' studioMode.value = 'editv2'
outputFocus.value = 'edit' outputFocus.value = 'edit'
const mode = payload.v2Mode === 'compose' || payload.v2Mode === 'refine' || payload.v2Mode === 'generate' const mode = payload.v2Mode === 'compose' || payload.v2Mode === 'refine' || payload.v2Mode === 'generate' || payload.v2Mode === 'iterate'
? payload.v2Mode ? payload.v2Mode
: 'edit' : 'edit'
setV2Mode(mode) setV2Mode(mode)
@@ -5552,7 +5611,7 @@ async function restoreStudioPayload(kind: string, payload: {
shotLoraStacks?: LoraStackItem[][] shotLoraStacks?: LoraStackItem[][]
negative?: string negative?: string
imagePipeline?: 'v1' | 'v2' imagePipeline?: 'v1' | 'v2'
v2Mode?: 'edit' | 'compose' | 'refine' | 'generate' v2Mode?: 'edit' | 'compose' | 'refine' | 'generate' | 'iterate'
v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i' v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i'
engine?: 'flux' | 'krea' engine?: 'flux' | 'krea'
snofsModel?: number snofsModel?: number
@@ -6332,7 +6391,7 @@ function applyStillSettings(still: LibraryStill) {
if (dest === 'imagev2') { if (dest === 'imagev2') {
studioMode.value = 'editv2' studioMode.value = 'editv2'
outputFocus.value = 'edit' outputFocus.value = 'edit'
const mode = settings?.mode === 'compose' || settings?.mode === 'refine' || settings?.mode === 'generate' const mode = settings?.mode === 'compose' || settings?.mode === 'refine' || settings?.mode === 'generate' || settings?.mode === 'iterate'
? settings.mode ? settings.mode
: 'edit' : 'edit'
setV2Mode(mode) setV2Mode(mode)
@@ -6392,6 +6451,7 @@ function stillUseAsInputMessage(still: LibraryStill) {
const dest = inferStillPipeline(still) const dest = inferStillPipeline(still)
const mode = still.settings?.mode const mode = still.settings?.mode
if (dest === 'imagev2' && mode === 'generate') return 'Generate settings and still loaded. Switch to Edit anytime — the image is already in Still A.' if (dest === 'imagev2' && mode === 'generate') return 'Generate settings and still loaded. Switch to Edit anytime — the image is already in Still A.'
if (dest === 'imagev2' && mode === 'iterate') return 'Iterate settings and still loaded.'
if (dest === 'imagev2' && mode === 'compose') { if (dest === 'imagev2' && mode === 'compose') {
return still.settings?.referenceStillId return still.settings?.referenceStillId
? 'Compose settings loaded with stills A and B.' ? 'Compose settings loaded with stills A and B.'
@@ -6976,6 +7036,7 @@ function absorbPastedImageBatch() {
} }
function imagePassUnit() { function imagePassUnit() {
if (v2Mode.value === 'iterate') return 'Iteration'
return imagePassMode.value === 'batch' ? 'Batch' : 'Pass' return imagePassMode.value === 'batch' ? 'Batch' : 'Pass'
} }
@@ -7102,8 +7163,12 @@ function setV2Mode(mode: ImageV2Mode) {
if (mode === 'generate') { if (mode === 'generate') {
v2Task.value = 't2i' v2Task.value = 't2i'
} }
if (mode === 'iterate') {
v2Task.value = 'scene'
imagePassMode.value = 'batch'
}
if (v2Engine.value === 'krea' && mode === 'edit') v2Denoise.value = IMAGE_V2_KREA_EDIT_DENOISE if (v2Engine.value === 'krea' && mode === 'edit') v2Denoise.value = IMAGE_V2_KREA_EDIT_DENOISE
if (v2Engine.value === 'krea' && mode === 'compose') v2Denoise.value = IMAGE_V2_KREA_COMPOSE_DENOISE if (v2Engine.value === 'krea' && (mode === 'compose' || mode === 'iterate')) v2Denoise.value = IMAGE_V2_KREA_COMPOSE_DENOISE
} }
function setV2Engine(engine: ImageV2Engine) { function setV2Engine(engine: ImageV2Engine) {
@@ -7228,10 +7293,17 @@ async function editImageV2() {
await ensureGenerationNotifyPermission() await ensureGenerationNotifyPermission()
const body = new FormData() const body = new FormData()
body.append('mode', v2Mode.value) body.append('mode', v2Mode.value)
body.append('task', v2Mode.value === 'generate' ? 't2i' : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'compose' ? v2Task.value : 'scene') const iterateGenerate = v2Mode.value === 'iterate' && !file.value
body.append('task', v2Mode.value === 'generate' || iterateGenerate
? 't2i'
: v2Mode.value === 'refine'
? 'refine'
: (v2Mode.value === 'compose' || (v2Mode.value === 'iterate' && editRefFile.value))
? v2Task.value
: 'scene')
if (v2Mode.value !== 'generate' && file.value) body.append('image_a', file.value) if (v2Mode.value !== 'generate' && file.value) body.append('image_a', file.value)
if (v2Mode.value === 'compose' && editRefFile.value) body.append('image_b', editRefFile.value) if ((v2Mode.value === 'compose' || v2Mode.value === 'iterate') && editRefFile.value) body.append('image_b', editRefFile.value)
if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate')) { if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate' && !iterateGenerate)) {
if (v2Mode.value === 'refine') { if (v2Mode.value === 'refine') {
const maskBlob = await refinePainter.value?.exportPng() const maskBlob = await refinePainter.value?.exportPng()
if (!maskBlob) { if (!maskBlob) {
@@ -7242,7 +7314,7 @@ async function editImageV2() {
} }
body.append('strength', String(clampImageV2Denoise(v2Denoise.value))) body.append('strength', String(clampImageV2Denoise(v2Denoise.value)))
} }
if (v2Mode.value === 'generate') { if (v2Mode.value === 'generate' || iterateGenerate) {
const size = generateSizeForAspect(aspect.value) const size = generateSizeForAspect(aspect.value)
body.append('width', String(size.width)) body.append('width', String(size.width))
body.append('height', String(size.height)) body.append('height', String(size.height))
@@ -7260,7 +7332,7 @@ async function editImageV2() {
body.append('consistency_clip', String(imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthClip ?? 0)) body.append('consistency_clip', String(imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthClip ?? 0))
body.append('steps', String(clampImageSteps(v2Steps.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT))) body.append('steps', String(clampImageSteps(v2Steps.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT)))
body.append('cfg', String(clampImageCfg(v2Cfg.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT))) body.append('cfg', String(clampImageCfg(v2Cfg.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT)))
if (v2Mode.value !== 'generate') { if (v2Mode.value !== 'generate' && !iterateGenerate) {
body.append('scaleToTotalPixels', String(v2ScaleOn.value)) body.append('scaleToTotalPixels', String(v2ScaleOn.value))
if (v2ScaleOn.value) body.append('megapixels', String(clampImageScaleMegapixels(v2Megapixels.value, 1))) if (v2ScaleOn.value) body.append('megapixels', String(clampImageScaleMegapixels(v2Megapixels.value, 1)))
} }
@@ -7270,9 +7342,9 @@ async function editImageV2() {
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value)) body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value)) body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
body.append('name', clipName.value.trim()) body.append('name', clipName.value.trim())
if (inputStillId.value) body.append('stillId', inputStillId.value) if (inputStillId.value && file.value) body.append('stillId', inputStillId.value)
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses)) if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
body.append('passMode', imagePassMode.value) body.append('passMode', iterateGenerate ? 'batch' : imagePassMode.value)
const v2Stack = filterLoraStackForImageEngine( const v2Stack = filterLoraStackForImageEngine(
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value), filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
v2Engine.value v2Engine.value
@@ -7301,9 +7373,11 @@ async function editImageV2() {
if (started.queued) { if (started.queued) {
const n = 1 + extraPasses.length const n = 1 + extraPasses.length
toast(n > 1 toast(n > 1
? (imagePassMode.value === 'batch' ? (v2Mode.value === 'iterate'
? `Queued ${n} Image v2 stills from the start image behind the current job.` ? `Queued ${n} iterations behind the current job.`
: `Queued ${n} Image v2 passes behind the current job.`) : imagePassMode.value === 'batch'
? `Queued ${n} Image v2 stills from the start image behind the current job.`
: `Queued ${n} Image v2 passes behind the current job.`)
: (v2Engine.value === 'krea' ? 'Queued Krea generate behind the current job.' : 'Queued Image v2 behind the current job.')) : (v2Engine.value === 'krea' ? 'Queued Krea generate behind the current job.' : 'Queued Image v2 behind the current job.'))
await refreshStudioQueue() await refreshStudioQueue()
return return
@@ -7324,7 +7398,15 @@ async function editImageV2() {
editChainLabel.value = extraPasses.length ? `${imagePassUnit()} 1` : '' editChainLabel.value = extraPasses.length ? `${imagePassUnit()} 1` : ''
editOverallProgress.value = 0 editOverallProgress.value = 0
editCompletedChainStep.value = 0 editCompletedChainStep.value = 0
const passNoun = v2Mode.value === 'compose' ? 'Compose' : v2Mode.value === 'refine' ? 'Refine' : v2Mode.value === 'generate' ? 'Generate' : 'Edit' const passNoun = v2Mode.value === 'iterate'
? 'Iteration'
: v2Mode.value === 'compose'
? 'Compose'
: v2Mode.value === 'refine'
? 'Refine'
: v2Mode.value === 'generate'
? 'Generate'
: 'Edit'
editActiveChainPlan.value = imagePassPlan(prompt.value.trim(), extraPasses, passNoun) editActiveChainPlan.value = imagePassPlan(prompt.value.trim(), extraPasses, passNoun)
startTimer('edit') startTimer('edit')
editDownloadName.value = downloadName editDownloadName.value = downloadName
+38 -21
View File
@@ -131,7 +131,7 @@ export default defineEventHandler(async (event) => {
const mode = parseImageV2Mode(fields.mode) const mode = parseImageV2Mode(fields.mode)
if (!mode) { if (!mode) {
throw createError({ statusCode: 400, statusMessage: 'mode must be edit, compose, refine, or generate' }) throw createError({ statusCode: 400, statusMessage: 'mode must be edit, compose, refine, generate, or iterate' })
} }
const task = parseImageV2Task( const task = parseImageV2Task(
fields.task, fields.task,
@@ -145,13 +145,19 @@ export default defineEventHandler(async (event) => {
const extraPasses = parsePasses(fields.passes) const extraPasses = parsePasses(fields.passes)
const passMode = parseImagePassMode(fields.passMode) const passMode = parseImagePassMode(fields.passMode)
const aspect = parseImageAspect(fields.aspect) const aspect = parseImageAspect(fields.aspect)
if (!promptMid && (mode === 'generate' || aspect === 'auto')) { if (!promptMid && (mode === 'generate' || mode === 'iterate' || aspect === 'auto')) {
throw createError({ statusCode: 400, statusMessage: 'A prompt is required' }) throw createError({ statusCode: 400, statusMessage: 'A prompt is required' })
} }
if (extraPasses.length && (mode === 'refine' || mode === 'generate')) { if (extraPasses.length && (mode === 'refine' || mode === 'generate')) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: 'Extra passes are only for Edit and Compose. Refine and Generate stay a single run.' statusMessage: 'Extra passes are only for Edit, Compose, and Iterate. Refine and Generate stay a single run.'
})
}
if (mode === 'iterate' && !extraPasses.length) {
throw createError({
statusCode: 400,
statusMessage: 'Iterate needs at least two prompts. Add more iterations, or use Generate / Edit for a single run.'
}) })
} }
if (extraPasses.some(item => !item.prompt)) { if (extraPasses.some(item => !item.prompt)) {
@@ -172,13 +178,24 @@ export default defineEventHandler(async (event) => {
} }
const ownerKey = libraryOwnerKey(event) const ownerKey = libraryOwnerKey(event)
const imageA = mode === 'generate' ? null : await resolveImageRef(ownerKey, fields.image_a, uploadedA) const imageA = mode === 'generate'
const imageB = mode === 'refine' || mode === 'generate' ? null : await resolveImageRef(ownerKey, fields.image_b, uploadedB) ? null
const mask = mode === 'refine' ? await resolveImageRef(ownerKey, fields.mask, uploadedMask) : null : await resolveImageRef(ownerKey, fields.image_a || fields.stillId, uploadedA).catch(() => null)
// Iterate with no still A is text-only generate iterations.
if (mode !== 'generate' && !imageA) { if (mode !== 'generate' && mode !== 'iterate' && !imageA) {
throw createError({ statusCode: 400, statusMessage: 'image_a is required' }) throw createError({ statusCode: 400, statusMessage: 'image_a is required' })
} }
if (mode === 'iterate' && !imageA && (uploadedA || String(fields.image_a || fields.stillId || '').trim())) {
throw createError({ statusCode: 400, statusMessage: 'Could not load still A for iterate' })
}
const imageB = mode === 'refine' || mode === 'generate' || (mode === 'iterate' && !imageA)
? null
: await resolveImageRef(ownerKey, fields.image_b, uploadedB).catch(() => null)
const mask = mode === 'refine' ? await resolveImageRef(ownerKey, fields.mask, uploadedMask) : null
const graphMode = mode === 'iterate'
? (imageA ? (imageB ? 'compose' as const : 'edit' as const) : 'generate' as const)
: mode
if (mode === 'refine' && !mask) { if (mode === 'refine' && !mask) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
@@ -220,7 +237,7 @@ export default defineEventHandler(async (event) => {
const cfg = engine === 'krea' const cfg = engine === 'krea'
? clampImageCfg(fields.cfg, IMAGE_V2_KREA_CFG) ? clampImageCfg(fields.cfg, IMAGE_V2_KREA_CFG)
: turbo ? IMAGE_V2_TURBO_CFG : clampImageCfg(fields.cfg, IMAGE_V2_CFG_DEFAULT) : turbo ? IMAGE_V2_TURBO_CFG : clampImageCfg(fields.cfg, IMAGE_V2_CFG_DEFAULT)
const megapixels = mode === 'generate' const megapixels = graphMode === 'generate'
? 0 ? 0
: fields.scaleToTotalPixels == null : fields.scaleToTotalPixels == null
? clampImageScaleMegapixels(fields.megapixels ?? fields.scaleMegapixels, 1) ? clampImageScaleMegapixels(fields.megapixels ?? fields.scaleMegapixels, 1)
@@ -230,31 +247,31 @@ export default defineEventHandler(async (event) => {
const seed = fields.seed && String(fields.seed) !== 'random' const seed = fields.seed && String(fields.seed) !== 'random'
? Number(fields.seed) ? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647) : Math.floor(Math.random() * 2_147_483_647)
const generateSize = mode === 'generate' const generateSize = graphMode === 'generate'
? generateSizeForAspect(aspect) ? generateSizeForAspect(aspect)
: clampImageV2Size(fields.width, fields.height, fields.aspect) : clampImageV2Size(fields.width, fields.height, fields.aspect)
const size = mode === 'generate' const size = graphMode === 'generate'
? { width: generateSize.width, height: generateSize.height } ? { width: generateSize.width, height: generateSize.height }
: imageDimensions(imageA!.data) : imageDimensions(imageA!.data)
const v2Mode = mode as ImageV2Mode const v2Mode = mode as ImageV2Mode
const v2Task = ( const v2Task = (
mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : mode === 'edit' ? 'scene' : task graphMode === 'generate' ? 't2i' : graphMode === 'refine' ? 'refine' : graphMode === 'edit' ? 'scene' : task
) as ImageV2Task ) as ImageV2Task
const refineStrength = mode === 'generate' const refineStrength = graphMode === 'generate'
? undefined ? undefined
: engine === 'krea' || mode === 'refine' : engine === 'krea' || graphMode === 'refine'
? clampImageV2Denoise( ? clampImageV2Denoise(
fields.strength, fields.strength,
mode === 'compose' ? IMAGE_V2_KREA_COMPOSE_DENOISE : mode === 'edit' ? IMAGE_V2_KREA_EDIT_DENOISE : IMAGE_V2_DENOISE_DEFAULT graphMode === 'compose' ? IMAGE_V2_KREA_COMPOSE_DENOISE : graphMode === 'edit' ? IMAGE_V2_KREA_EDIT_DENOISE : IMAGE_V2_DENOISE_DEFAULT
) )
: undefined : undefined
const generateConsistencyModel = clampImageV2Strength( const generateConsistencyModel = clampImageV2Strength(
fields.consistency_model, fields.consistency_model,
mode === 'generate' || engine === 'krea' ? 0 : IMAGE_V2_CONSISTENCY_MODEL graphMode === 'generate' || engine === 'krea' ? 0 : IMAGE_V2_CONSISTENCY_MODEL
) )
const generateConsistencyClip = clampImageV2Strength( const generateConsistencyClip = clampImageV2Strength(
fields.consistency_clip, fields.consistency_clip,
mode === 'generate' || engine === 'krea' ? 0 : IMAGE_V2_CONSISTENCY_CLIP graphMode === 'generate' || engine === 'krea' ? 0 : IMAGE_V2_CONSISTENCY_CLIP
) )
const conceptAllowed = isXaigenStudio() const conceptAllowed = isXaigenStudio()
const generateSnofsFallback = 0 const generateSnofsFallback = 0
@@ -325,7 +342,7 @@ export default defineEventHandler(async (event) => {
promptPost: promptPost || undefined, promptPost: promptPost || undefined,
name: clipName, name: clipName,
folderId, folderId,
aspect: mode === 'generate' ? generateSize.aspect : aspect, aspect: graphMode === 'generate' ? generateSize.aspect : aspect,
width: size?.width || 0, width: size?.width || 0,
height: size?.height || 0, height: size?.height || 0,
steps, steps,
@@ -349,10 +366,10 @@ export default defineEventHandler(async (event) => {
queueAutoRun: false, queueAutoRun: false,
negative: String(fields.negative || '').trim(), negative: String(fields.negative || '').trim(),
passes: extraPasses, passes: extraPasses,
passMode, passMode: mode === 'iterate' && graphMode === 'generate' ? 'batch' : passMode,
referenceStillId: savedRef?.id, referenceStillId: savedRef?.id,
referenceStillFilename: savedRef?.filename, referenceStillFilename: savedRef?.filename,
scaleToTotalPixels: mode !== 'generate' && megapixels > 0, scaleToTotalPixels: graphMode !== 'generate' && megapixels > 0,
scaleMegapixels: megapixels, scaleMegapixels: megapixels,
imagePipeline: 'v2', imagePipeline: 'v2',
v2Mode, v2Mode,
@@ -386,7 +403,7 @@ export default defineEventHandler(async (event) => {
mode: v2Mode, mode: v2Mode,
task: v2Task, task: v2Task,
engine, engine,
workflow: imageV2WorkflowFile(v2Mode, engine), workflow: imageV2WorkflowFile(v2Mode, engine, { hasImage: Boolean(imageA), hasReference: Boolean(imageB) }),
width: size?.width || 0, width: size?.width || 0,
height: size?.height || 0, height: size?.height || 0,
strength: refineStrength, strength: refineStrength,
+37 -29
View File
@@ -11,6 +11,7 @@ import { comfyJobPrefix } from '~/utils/outputNames'
import { restorePromptParts, wrappedPromptForComfy } from '~/utils/promptParts' import { restorePromptParts, wrappedPromptForComfy } from '~/utils/promptParts'
import { isXaigenStudio } from '~/utils/videoModels' import { isXaigenStudio } from '~/utils/videoModels'
import { import {
imageV2GraphMode,
imageV2StackSpecials, imageV2StackSpecials,
mergeLegacyImageV2Loras, mergeLegacyImageV2Loras,
parseImageV2Engine, parseImageV2Engine,
@@ -52,20 +53,24 @@ export type EditV2RunParams = {
export async function runEditV2(job: Job, params: EditV2RunParams) { export async function runEditV2(job: Job, params: EditV2RunParams) {
const library = job.library const library = job.library
if (!library) throw new Error('Edit job is missing library metadata') if (!library) throw new Error('Edit job is missing library metadata')
if (params.mode === 'refine' && !params.mask) { const graphMode = imageV2GraphMode(params.mode, {
hasImage: Boolean(params.image),
hasReference: Boolean(params.reference)
})
if (graphMode === 'refine' && !params.mask) {
throw new Error('Refine requires a mask. Refusing to fall back to Edit.') throw new Error('Refine requires a mask. Refusing to fall back to Edit.')
} }
if (params.mode === 'compose' && !params.reference) { if (graphMode === 'compose' && !params.reference) {
throw new Error('Compose requires image B. Refusing to fall back to one-image generation.') throw new Error('Compose requires image B. Refusing to fall back to one-image generation.')
} }
if (params.mode === 'edit' && params.reference) { if (graphMode === 'edit' && params.reference && params.mode !== 'iterate') {
throw new Error('Edit mode takes one image. Use Compose for two stills.') throw new Error('Edit mode takes one image. Use Compose for two stills.')
} }
if (params.mode !== 'generate' && !params.image) { if (graphMode !== 'generate' && !params.image) {
throw new Error('This v2 mode requires still A.') throw new Error('This v2 mode requires still A.')
} }
const canChain = 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) ? (params.passes || []).map(item => ({ prompt: String(item.prompt || '').trim() })).filter(item => item.prompt)
: [] : []
@@ -78,8 +83,9 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
...extraPasses.map(item => item.prompt) ...extraPasses.map(item => item.prompt)
] ]
const chainTotal = prompts.length const chainTotal = prompts.length
const batch = params.passMode !== 'chain' // Text-only iterate is always independent generations. With a still, honor batch vs chain.
const unit = batch ? 'Batch' : 'Pass' const batch = graphMode === 'generate' || params.passMode !== 'chain'
const unit = params.mode === 'iterate' ? 'Iteration' : (batch ? 'Batch' : 'Pass')
library.chainTotal = chainTotal library.chainTotal = chainTotal
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
@@ -96,7 +102,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
job.imageComfyHost = getComfyHost() job.imageComfyHost = getComfyHost()
await withImageComfyHost(job.imageComfyHost, async () => { await withImageComfyHost(job.imageComfyHost, async () => {
const generate = params.mode === 'generate' const generate = graphMode === 'generate'
const engine = parseImageV2Engine(params.engine, 'flux') const engine = parseImageV2Engine(params.engine, 'flux')
const engineLabel = engine === 'krea' ? 'Krea' : 'Flux' const engineLabel = engine === 'krea' ? 'Krea' : 'Flux'
const { applyImageFill } = await import('~/server/utils/imagePad') const { applyImageFill } = await import('~/server/utils/imagePad')
@@ -113,7 +119,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
const krea = engine === 'krea' ? await resolveKreaGenerateAssets() : null const krea = engine === 'krea' ? await resolveKreaGenerateAssets() : null
const appliedStack = mergeLegacyImageV2Loras(params.loraStack?.length ? params.loraStack : library.loraStack, { const appliedStack = mergeLegacyImageV2Loras(params.loraStack?.length ? params.loraStack : library.loraStack, {
engine, engine,
mode: params.mode, mode: graphMode,
xaigen: isXaigenStudio(), xaigen: isXaigenStudio(),
snofsModel: params.snofsModel, snofsModel: params.snofsModel,
snofsClip: params.snofsClip, snofsClip: params.snofsClip,
@@ -127,7 +133,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
const consistencyModel = appliedSpecials.consistency?.strengthModel ?? params.consistencyModel const consistencyModel = appliedSpecials.consistency?.strengthModel ?? params.consistencyModel
const consistencyClip = appliedSpecials.consistency?.strengthClip ?? params.consistencyClip const consistencyClip = appliedSpecials.consistency?.strengthClip ?? params.consistencyClip
if (!generate && params.mode === 'compose' && params.reference) { if (!generate && graphMode === 'compose' && params.reference) {
uploadedRef = await uploadImage({ uploadedRef = await uploadImage({
...params.reference, ...params.reference,
filename: `ref_${params.reference.filename || 'image_b.png'}` filename: `ref_${params.reference.filename || 'image_b.png'}`
@@ -163,17 +169,17 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
emitChainJob(job, { emitChainJob(job, {
type: 'status', type: 'status',
message: generate message: generate
? `Queueing ${engineLabel} generate on Beast...` ? `Queueing ${engineLabel} iterate generate on Beast...`
: params.mode === 'refine' : graphMode === 'refine'
? 'Uploading canvas and mask to Beast...' ? 'Uploading canvas and mask to Beast...'
: params.mode === 'compose' ? 'Uploading stills A and B to Beast...' : 'Uploading still A to Beast...', : graphMode === 'compose' ? 'Uploading stills A and B to Beast...' : 'Uploading still A to Beast...',
progress: generate ? 12 : 8 progress: generate ? 12 : 8
}) })
if (index === 0 && !generate && image) { if (index === 0 && !generate && image) {
const filled = await applyImageFill({ const filled = await applyImageFill({
image, image,
mask: params.mode === 'refine' ? mask : null, mask: graphMode === 'refine' ? mask : null,
aspect: params.aspect || 'auto' aspect: params.aspect || 'auto'
}) })
image = filled.image image = filled.image
@@ -199,7 +205,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
prompt = composeFillPrompt(prompt, true) prompt = composeFillPrompt(prompt, true)
} }
if (index === 0 && params.mode === 'refine' && mask && !uploadedMask) { if (index === 0 && graphMode === 'refine' && mask && !uploadedMask) {
uploadedMask = await uploadImage({ uploadedMask = await uploadImage({
...mask, ...mask,
filename: `mask_${mask.filename || 'refine-mask.png'}` filename: `mask_${mask.filename || 'refine-mask.png'}`
@@ -212,19 +218,21 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
if (!generate) { if (!generate) {
emitChainJob(job, { emitChainJob(job, {
type: 'status', type: 'status',
message: params.mode === 'refine' message: params.mode === 'iterate'
? `Queueing ${engineLabel} v2 refine on Beast...` ? `Queueing ${engineLabel} iterate (${graphMode}) on Beast...`
: params.mode === 'compose' : graphMode === 'refine'
? `Queueing ${engineLabel} v2 compose (${params.task}) on Beast...` ? `Queueing ${engineLabel} v2 refine on Beast...`
: `Queueing ${engineLabel} v2 edit on Beast...`, : graphMode === 'compose'
? `Queueing ${engineLabel} v2 compose (${params.task}) on Beast...`
: `Queueing ${engineLabel} v2 edit on Beast...`,
progress: 12 progress: 12
}) })
} }
const built = buildImageV2Workflow({ const built = buildImageV2Workflow({
mode: params.mode, mode: graphMode,
engine, engine,
task: params.task, task: generate ? 't2i' : params.task,
prompt: wrappedPromptForComfy(library, prompt, index === 0 ? params.prompt : prompt), prompt: wrappedPromptForComfy(library, prompt, index === 0 ? params.prompt : prompt),
negative: params.negative, negative: params.negative,
imageAName: uploaded?.name, imageAName: uploaded?.name,
@@ -305,7 +313,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
kind: 'imagev2', kind: 'imagev2',
mode: params.mode, mode: params.mode,
engine, engine,
task: params.task, task: generate ? 't2i' : params.task,
negative: params.negative, negative: params.negative,
snofsModel, snofsModel,
snofsClip, snofsClip,
@@ -313,12 +321,12 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
consistencyClip, consistencyClip,
steps: params.steps, steps: params.steps,
cfg: params.cfg, cfg: params.cfg,
megapixels: params.mode === 'generate' || !(params.megapixels > 0) ? undefined : params.megapixels, megapixels: generate || !(params.megapixels > 0) ? undefined : params.megapixels,
scaleToTotalPixels: params.mode === 'generate' ? undefined : params.megapixels > 0, scaleToTotalPixels: generate ? undefined : params.megapixels > 0,
turbo: params.turbo === true, turbo: params.turbo === true,
strength: params.mode === 'generate' ? undefined : params.strength, strength: generate ? undefined : params.strength,
width: params.mode === 'generate' ? params.width : undefined, width: generate ? params.width : undefined,
height: params.mode === 'generate' ? params.height : undefined, height: generate ? params.height : undefined,
aspect: params.aspect || 'auto', aspect: params.aspect || 'auto',
seed, seed,
sourceStillId: params.sourceStillId || library.stillId, sourceStillId: params.sourceStillId || library.stillId,
@@ -362,7 +370,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
emitChainJob(job, { emitChainJob(job, {
type: 'complete', type: 'complete',
message: chainTotal > 1 message: chainTotal > 1
? `${engineLabel} v2 ${batch ? 'batch' : 'chain'} finished on Beast` ? `${engineLabel} ${params.mode === 'iterate' ? 'iterate' : `v2 ${batch ? 'batch' : 'chain'}`} finished on Beast`
: `${engineLabel} v2 finished on Beast`, : `${engineLabel} v2 finished on Beast`,
progress: 100, progress: 100,
stillId: still?.id, stillId: still?.id,
+33 -26
View File
@@ -58,7 +58,7 @@ export interface StudioJobPayload {
scaleToTotalPixels?: boolean scaleToTotalPixels?: boolean
scaleMegapixels?: number scaleMegapixels?: number
imagePipeline?: 'v1' | 'v2' imagePipeline?: 'v1' | 'v2'
v2Mode?: 'edit' | 'compose' | 'refine' | 'generate' v2Mode?: 'edit' | 'compose' | 'refine' | 'generate' | 'iterate'
v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i' v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i'
engine?: 'flux' | 'krea' engine?: 'flux' | 'krea'
snofsModel?: number snofsModel?: number
@@ -1017,7 +1017,8 @@ async function startStudioEditJob(item: StudioJob) {
const { runEditV2 } = await import('~/server/utils/imageChainV2') const { runEditV2 } = await import('~/server/utils/imageChainV2')
const payload = item.payload const payload = item.payload
const generate = payload.imagePipeline === 'v2' && payload.v2Mode === 'generate' const iterate = payload.imagePipeline === 'v2' && payload.v2Mode === 'iterate'
const generate = payload.imagePipeline === 'v2' && (payload.v2Mode === 'generate' || (iterate && !payload.stillId))
if (!generate && (!payload.stillId || !existsSync(stillPath(item.ownerKey, payload.stillId)))) { if (!generate && (!payload.stillId || !existsSync(stillPath(item.ownerKey, payload.stillId)))) {
throw new Error('The input still is missing from the library') throw new Error('The input still is missing from the library')
} }
@@ -1038,30 +1039,36 @@ async function startStudioEditJob(item: StudioJob) {
: null : null
if (payload.imagePipeline === 'v2') { if (payload.imagePipeline === 'v2') {
const mode = payload.v2Mode === 'compose' const mode = payload.v2Mode === 'iterate'
? 'compose' ? 'iterate'
: payload.v2Mode === 'refine' : payload.v2Mode === 'compose'
? 'refine' ? 'compose'
: payload.v2Mode === 'generate' : payload.v2Mode === 'refine'
? 'generate' ? 'refine'
: 'edit' : payload.v2Mode === 'generate'
? 'generate'
: 'edit'
const graphMode = mode === 'iterate'
? (image ? (reference ? 'compose' : 'edit') : 'generate')
: mode
const maskId = payload.maskStillId const maskId = payload.maskStillId
const mask = mode === 'refine' && maskId && existsSync(stillPath(item.ownerKey, maskId)) const mask = graphMode === 'refine' && maskId && existsSync(stillPath(item.ownerKey, maskId))
? { ? {
filename: payload.maskStillFilename || 'refine-mask.png', filename: payload.maskStillFilename || 'refine-mask.png',
data: readFileSync(stillPath(item.ownerKey, maskId)), data: readFileSync(stillPath(item.ownerKey, maskId)),
type: 'image/png' type: 'image/png'
} }
: null : null
if (mode === 'refine' && !mask) { if (graphMode === 'refine' && !mask) {
throw new Error('Refine requires a mask. Refusing to fall back to Edit.') throw new Error('Refine requires a mask. Refusing to fall back to Edit.')
} }
if (mode === 'compose' && !reference) { if (graphMode === 'compose' && !reference) {
throw new Error('Compose requires image B. Refusing to fall back to one-image generation.') throw new Error('Compose requires image B. Refusing to fall back to one-image generation.')
} }
if (mode === 'edit' && reference) { if (mode === 'edit' && reference) {
throw new Error('Edit mode takes one image. Use Compose for two stills.') throw new Error('Edit mode takes one image. Use Compose for two stills.')
} }
const passCount = (mode === 'edit' || mode === 'compose' || mode === 'iterate') ? (payload.passes?.length || 0) : 0
live = createEditLiveJob({ live = createEditLiveJob({
steps: payload.steps, steps: payload.steps,
hideThumbnail: payload.hideThumbnail, hideThumbnail: payload.hideThumbnail,
@@ -1088,9 +1095,9 @@ async function startStudioEditJob(item: StudioJob) {
familyId: item.familyId, familyId: item.familyId,
chainIndex: 0, chainIndex: 0,
chainStep: 1, chainStep: 1,
chainTotal: 1 + ((mode === 'edit' || mode === 'compose') ? (payload.passes?.length || 0) : 0), chainTotal: 1 + passCount,
chainLabel: (mode === 'edit' || mode === 'compose') && payload.passes?.length chainLabel: passCount
? (payload.passMode === 'chain' ? 'Pass 1' : 'Batch 1') ? (mode === 'iterate' ? 'Iteration 1' : (payload.passMode === 'chain' ? 'Pass 1' : 'Batch 1'))
: undefined, : undefined,
passes: payload.passes, passes: payload.passes,
...persistLoraFields(payload.loraStack || payload.loraName) ...persistLoraFields(payload.loraStack || payload.loraName)
@@ -1102,30 +1109,30 @@ async function startStudioEditJob(item: StudioJob) {
void runEditV2(live, { void runEditV2(live, {
mode, mode,
engine: v2Engine, engine: v2Engine,
task: payload.v2Task || 'scene', task: payload.v2Task || (graphMode === 'generate' ? 't2i' : 'scene'),
image: mode === 'generate' ? null : image, image: graphMode === 'generate' ? null : image,
reference: mode === 'refine' || mode === 'generate' ? null : reference, reference: graphMode === 'compose' ? reference : null,
mask, mask,
prompt: payload.prompt, prompt: payload.prompt,
passes: payload.passes || [],
passMode: payload.passMode === 'chain' ? 'chain' : 'batch',
negative: payload.negative || '', negative: payload.negative || '',
steps: payload.steps, steps: payload.steps,
seed: live.library?.seed || payload.seed, 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,
consistencyModel: v2Specials.consistency?.strengthModel ?? payload.consistencyModel ?? (mode === 'generate' || payload.engine === 'krea' ? 0 : 0.7), consistencyModel: v2Specials.consistency?.strengthModel ?? payload.consistencyModel ?? 0,
consistencyClip: v2Specials.consistency?.strengthClip ?? payload.consistencyClip ?? (mode === 'generate' || payload.engine === 'krea' ? 0 : 0.7), consistencyClip: v2Specials.consistency?.strengthClip ?? payload.consistencyClip ?? 0,
megapixels: payload.scaleToTotalPixels === false ? 0 : (payload.scaleMegapixels ?? 1), megapixels: payload.scaleMegapixels || 0,
strength: payload.refineStrength, strength: payload.refineStrength,
aspect: payload.aspect || 'auto',
width: payload.width, width: payload.width,
height: payload.height, height: payload.height,
turbo: payload.turbo === true, turbo: payload.turbo === true,
aspect: payload.aspect,
sourceStillId: payload.stillId, sourceStillId: payload.stillId,
referenceStillId: payload.referenceStillId, referenceStillId: payload.referenceStillId,
loraStack: payload.loraStack loraStack: payload.loraStack,
passes: payload.passes,
passMode: payload.passMode === 'chain' ? 'chain' : 'batch',
}).catch((error) => { }).catch((error) => {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
if (live && live.status !== 'error' && live.status !== 'cancelled' && live.status !== 'deferred') { if (live && live.status !== 'error' && live.status !== 'cancelled' && live.status !== 'deferred') {
+25 -10
View File
@@ -6,13 +6,26 @@ import {
type LoraStackItem type LoraStackItem
} from '~/utils/loras' } from '~/utils/loras'
export const IMAGE_V2_MODES = ['edit', 'compose', 'refine', 'generate'] as const export const IMAGE_V2_MODES = ['edit', 'compose', 'refine', 'generate', 'iterate'] as const
export const IMAGE_V2_TASKS = ['scene', 'identity', 'outfit', 'face_lock', 'refine', 't2i'] as const export const IMAGE_V2_TASKS = ['scene', 'identity', 'outfit', 'face_lock', 'refine', 't2i'] as const
export const IMAGE_V2_ENGINES = ['flux', 'krea'] as const export const IMAGE_V2_ENGINES = ['flux', 'krea'] as const
export type ImageV2Mode = (typeof IMAGE_V2_MODES)[number] export type ImageV2Mode = (typeof IMAGE_V2_MODES)[number]
export type ImageV2Task = (typeof IMAGE_V2_TASKS)[number] export type ImageV2Task = (typeof IMAGE_V2_TASKS)[number]
export type ImageV2Engine = (typeof IMAGE_V2_ENGINES)[number] export type ImageV2Engine = (typeof IMAGE_V2_ENGINES)[number]
export type ImageV2GraphMode = Exclude<ImageV2Mode, 'iterate'>
/** Iterate picks a real graph from the attached stills: none → generate, A → edit, A+B → compose. */
export function iterateGraphMode(opts: { hasImage?: boolean; hasReference?: boolean }): Exclude<ImageV2GraphMode, 'refine'> {
if (!opts.hasImage) return 'generate'
if (opts.hasReference) return 'compose'
return 'edit'
}
export function imageV2GraphMode(mode: ImageV2Mode, opts: { hasImage?: boolean; hasReference?: boolean } = {}): ImageV2GraphMode {
if (mode === 'iterate') return iterateGraphMode(opts)
return mode
}
export const IMAGE_V2_STEPS_DEFAULT = 24 export const IMAGE_V2_STEPS_DEFAULT = 24
export const IMAGE_V2_CFG_DEFAULT = 4 export const IMAGE_V2_CFG_DEFAULT = 4
@@ -97,16 +110,17 @@ export function parseImageV2Engine(raw: unknown, fallback: ImageV2Engine = 'flux
return IMAGE_V2_ENGINES.includes(value as ImageV2Engine) ? value as ImageV2Engine : fallback return IMAGE_V2_ENGINES.includes(value as ImageV2Engine) ? value as ImageV2Engine : fallback
} }
export function imageV2WorkflowFile(mode: ImageV2Mode, engine: ImageV2Engine = 'flux') { export function imageV2WorkflowFile(mode: ImageV2Mode, engine: ImageV2Engine = 'flux', opts: { hasImage?: boolean; hasReference?: boolean } = {}) {
const graph = imageV2GraphMode(mode, opts)
if (engine === 'krea') { if (engine === 'krea') {
if (mode === 'generate') return 'krea_v2_generate.json' if (graph === 'generate') return 'krea_v2_generate.json'
if (mode === 'refine') return 'krea_v2_refine.json' if (graph === 'refine') return 'krea_v2_refine.json'
if (mode === 'compose') return 'krea_v2_compose.json' if (graph === 'compose') return 'krea_v2_compose.json'
return 'krea_v2_edit.json' return 'krea_v2_edit.json'
} }
if (mode === 'generate') return 'klein_v2_generate.json' if (graph === 'generate') return 'klein_v2_generate.json'
if (mode === 'refine') return 'klein_v2_refine.json' if (graph === 'refine') return 'klein_v2_refine.json'
if (mode === 'compose') return 'klein_v2_compose.json' if (graph === 'compose') return 'klein_v2_compose.json'
return 'klein_v2_edit.json' return 'klein_v2_edit.json'
} }
@@ -150,9 +164,10 @@ export function clampImageV2Size(width: unknown, height: unknown, aspect?: unkno
} }
} }
export function composeImageV2Prompt(mode: ImageV2Mode, task: ImageV2Task, prompt: string) { export function composeImageV2Prompt(mode: ImageV2Mode, task: ImageV2Task, prompt: string, opts: { hasImage?: boolean; hasReference?: boolean } = {}) {
const body = String(prompt || '').trim() const body = String(prompt || '').trim()
if (mode === 'generate' || mode === 'refine' || mode !== 'compose' || task === 'scene' || task === 't2i') return body const graph = imageV2GraphMode(mode, opts)
if (graph === 'generate' || graph === 'refine' || graph !== 'compose' || task === 'scene' || task === 't2i') return body
const header = IMAGE_V2_ROLE_HEADERS[task as Exclude<ImageV2Task, 'scene' | 'refine' | 't2i'>] const header = IMAGE_V2_ROLE_HEADERS[task as Exclude<ImageV2Task, 'scene' | 'refine' | 't2i'>]
return header ? `${header}\n\n${body}` : body return header ? `${header}\n\n${body}` : body
} }
+8 -6
View File
@@ -92,7 +92,7 @@ function stillDisplayLoraStack(settings: StillGenerationSettings) {
export function normalizeStillSettings(raw: unknown): StillGenerationSettings | undefined { export function normalizeStillSettings(raw: unknown): StillGenerationSettings | undefined {
if (!raw || typeof raw !== 'object') return undefined if (!raw || typeof raw !== 'object') return undefined
const value = raw as Record<string, unknown> const value = raw as Record<string, unknown>
const mode = value.mode === 'compose' || value.mode === 'refine' || value.mode === 'generate' || value.mode === 'edit' const mode = value.mode === 'compose' || value.mode === 'refine' || value.mode === 'generate' || value.mode === 'edit' || value.mode === 'iterate'
? value.mode ? value.mode
: undefined : undefined
const engine = value.engine === 'krea' || value.engine === 'flux' ? value.engine : undefined const engine = value.engine === 'krea' || value.engine === 'flux' ? value.engine : undefined
@@ -155,11 +155,13 @@ export function stillModeLabel(still: { filename?: string; settings?: StillGener
if (settings?.kind === 'imagev2' || settings?.mode) { if (settings?.kind === 'imagev2' || settings?.mode) {
const mode = settings.mode === 'generate' const mode = settings.mode === 'generate'
? 'Generate' ? 'Generate'
: settings.mode === 'compose' : settings.mode === 'iterate'
? 'Compose' ? 'Iterate'
: settings.mode === 'refine' : settings.mode === 'compose'
? 'Refine' ? 'Compose'
: 'Edit' : settings.mode === 'refine'
? 'Refine'
: 'Edit'
const task = settings.mode === 'compose' ? composeTaskLabel(settings.task) : '' const task = settings.mode === 'compose' ? composeTaskLabel(settings.task) : ''
const engine = settings.engine === 'krea' ? 'Krea' : settings.engine === 'flux' ? 'Flux' : '' const engine = settings.engine === 'krea' ? 'Krea' : settings.engine === 'flux' ? 'Flux' : ''
return [engine ? `Image v2 · ${mode} · ${engine}` : `Image v2 · ${mode}`, task].filter(Boolean).join(' · ') return [engine ? `Image v2 · ${mode} · ${engine}` : `Image v2 · ${mode}`, task].filter(Boolean).join(' · ')