Add Image v2 Generate for Klein text-to-image.

New sibling graph and mode so T2I does not borrow Edit, Compose, or Refine. Stills are ignored; size is width x height only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 22:56:33 -05:00
co-authored by Cursor
parent a2895b8054
commit b35f9af10d
9 changed files with 511 additions and 112 deletions
+135 -24
View File
@@ -65,7 +65,9 @@
</div>
<h2 class="font-display text-2xl font-bold">Input</h2>
<p class="text-sm text-zinc-400">{{ studioMode === 'editv2'
? (v2Mode === 'refine'
? (v2Mode === 'generate'
? 'Generate: no reference image. Prompt only. Klein 9B Base text-to-image.'
: v2Mode === 'refine'
? 'Refine: paint a mask on Still A. Prompt only what should change in the painted area. Strength is denoise in that region.'
: v2Mode === 'compose'
? 'Compose: still A is the person/body. Still B is the face or outfit. Two stills required — no silent one-image fallback.'
@@ -76,6 +78,7 @@
</div>
<div
v-if="!(studioMode === 'editv2' && v2Mode === 'generate')"
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 }"
@dragover.prevent="dragging = true"
@@ -133,6 +136,14 @@
>
Refine
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="v2Mode === 'generate' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="setV2Mode('generate')"
>
Generate
</button>
<label v-if="v2Mode === 'compose'" class="ml-auto text-xs">
<span class="mr-2 text-zinc-500">Task</span>
<select
@@ -192,7 +203,7 @@
<div>
<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 === '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 === 'refine' ? 'Refine prompt' : studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
<div class="flex flex-wrap items-center gap-3">
<button
v-if="identityPrompting"
@@ -276,6 +287,7 @@
/>
</label>
</div>
<p v-if="studioMode === 'editv2' && v2Mode === 'generate'" class="mt-3 text-xs text-zinc-500">No reference image. Prompt only.</p>
<div v-if="studioMode === 'editv2' && v2Mode === 'refine'" class="mt-3 space-y-2">
<p class="text-xs text-zinc-500">Prompt only what should change in the painted area.</p>
<div class="flex flex-wrap gap-2">
@@ -698,7 +710,40 @@
/>
</div>
<div v-else-if="studioMode === 'editv2'" class="space-y-3">
<p class="text-xs text-zinc-500">Klein v2 · 9B Base · euler. Edit, Compose, and Refine are separate graphs. Shares the desktop GPU with video.</p>
<p class="text-xs text-zinc-500">Klein v2 · 9B Base · euler. Edit, Compose, Refine, and Generate are separate graphs. Shares the desktop GPU with video.</p>
<div v-if="v2Mode === 'generate'" class="space-y-2 rounded-2xl border border-white/10 bg-zinc-950/40 p-3">
<p class="text-xs text-zinc-400">Size · near 1 MP</p>
<div class="flex flex-wrap gap-2">
<button
v-for="(pair, aspect) in IMAGE_V2_GENERATE_ASPECTS"
:key="aspect"
type="button"
class="rounded-full border px-2.5 py-1 text-xs"
:class="v2Width === pair[0] && v2Height === pair[1] ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="setGenerateSize(aspect)"
>
{{ aspect }} · {{ pair[0] }}×{{ pair[1] }}
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-2.5 py-1 text-xs"
:class="v2SnofsModel === 0 && v2SnofsClip === 0 ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="applyGenerateLook('sfw')"
>
SFW scene
</button>
<button
type="button"
class="rounded-full border px-2.5 py-1 text-xs"
:class="v2SnofsModel === IMAGE_V2_GENERATE_SNOFS.snofsModel && v2SnofsClip === IMAGE_V2_GENERATE_SNOFS.snofsClip ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="applyGenerateLook('snofs')"
>
SNOF T2I
</button>
</div>
</div>
<div v-if="v2Mode === 'refine'" class="space-y-2 rounded-2xl border border-white/10 bg-zinc-950/40 p-3">
<label class="block text-sm">
<span class="mb-1 block text-zinc-300">Strength (denoise)</span>
@@ -750,11 +795,11 @@
<span class="mb-1 block text-zinc-400">SNOFS CLIP</span>
<input v-model.number="v2SnofsClip" type="number" min="0" max="2" step="0.05" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2">
</label>
<label class="block text-sm">
<label v-if="v2Mode !== 'generate'" class="block text-sm">
<span class="mb-1 block text-zinc-400">Consistency Model</span>
<input v-model.number="v2ConsistencyModel" type="number" min="0" max="2" step="0.05" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2">
</label>
<label class="block text-sm">
<label v-if="v2Mode !== 'generate'" class="block text-sm">
<span class="mb-1 block text-zinc-400">Consistency CLIP</span>
<input v-model.number="v2ConsistencyClip" type="number" min="0" max="2" step="0.05" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2">
</label>
@@ -770,7 +815,7 @@
<span class="mb-1 block text-zinc-400">Seed</span>
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="random">
</label>
<label class="block text-sm">
<label v-if="v2Mode !== 'generate'" class="block text-sm">
<span class="mb-1 block text-zinc-400">Scale to MP</span>
<input v-model.number="v2Megapixels" type="number" :min="IMAGE_SCALE_MP_MIN" :max="IMAGE_SCALE_MP_MAX" :step="IMAGE_SCALE_MP_STEP" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2">
</label>
@@ -1327,6 +1372,13 @@
>
Use as input still
</button>
<button
type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
@click="sendToEdit"
>
Send to Edit
</button>
<button
type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
@@ -1864,6 +1916,11 @@ import {
IMAGE_V2_DENOISE_MAX,
IMAGE_V2_DENOISE_MIN,
IMAGE_V2_DENOISE_STEP,
IMAGE_V2_GENERATE_ASPECTS,
IMAGE_V2_GENERATE_HEIGHT,
IMAGE_V2_GENERATE_SNOFS,
IMAGE_V2_GENERATE_SFW,
IMAGE_V2_GENERATE_WIDTH,
IMAGE_V2_REFINE_FACE,
IMAGE_V2_REFINE_FACE_PROMPT,
IMAGE_V2_REFINE_HAND,
@@ -1872,7 +1929,9 @@ import {
IMAGE_V2_SNOFS_MODEL,
IMAGE_V2_STEPS_DEFAULT,
clampImageV2Denoise,
clampImageV2Size,
clampImageV2Strength,
type ImageV2GenerateAspect,
type ImageV2Mode,
type ImageV2PresetSettings,
type ImageV2Task
@@ -2153,6 +2212,8 @@ const v2Steps = ref(IMAGE_V2_STEPS_DEFAULT)
const v2Cfg = ref(IMAGE_V2_CFG_DEFAULT)
const v2Megapixels = ref(1)
const v2Turbo = ref(false)
const v2Width = ref(IMAGE_V2_GENERATE_WIDTH)
const v2Height = ref(IMAGE_V2_GENERATE_HEIGHT)
const v2Denoise = ref(IMAGE_V2_DENOISE_DEFAULT)
const refineMaskDirty = ref(false)
const refinePainter = ref<{ exportPng: () => Promise<Blob | null>; clear: () => void } | null>(null)
@@ -2532,14 +2593,15 @@ const stillBHint = computed(() => {
return 'Still A is the edit image. Still B is style or background.'
})
const editV2Blocked = computed(() => {
if (!file.value || !prompt.value.trim() || !folderId.value) return true
if (!prompt.value.trim() || !folderId.value) return true
if (v2Mode.value !== 'generate' && !file.value) return true
if (v2Mode.value === 'compose' && !editRefFile.value) return true
if (v2Mode.value === 'refine' && !refineMaskDirty.value) return true
if (!comfyOk.value && !imageComfyOk.value) return true
return false
})
const editV2BlockReason = computed(() => {
if (!file.value) return 'Load still A first.'
if (v2Mode.value !== 'generate' && !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 === 'refine' && !refineMaskDirty.value) return 'Paint a mask on Still A first. Refine will not fall back to Edit.'
if (!prompt.value.trim()) return 'Write a prompt first.'
@@ -2550,7 +2612,7 @@ const editV2BlockReason = computed(() => {
})
const editV2SubmitLabel = computed(() => {
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
const label = v2Mode.value === 'refine' ? 'Refine image' : v2Mode.value === 'compose' ? 'Compose image' : 'Edit image'
const label = v2Mode.value === 'generate' ? 'Generate image' : v2Mode.value === 'refine' ? 'Refine image' : v2Mode.value === 'compose' ? 'Compose image' : 'Edit image'
return occupied ? `Queue ${label.toLowerCase()}` : label
})
const generateLabel = computed(() => {
@@ -2608,6 +2670,7 @@ const composedIdentityPrompt = computed(() => {
})
const promptPlaceholder = computed(() => {
if (studioMode.value === 'editv2') {
if (v2Mode.value === 'generate') return 'A red cube on a white table, studio light.'
if (v2Mode.value === 'refine') return 'Prompt only what should change in the painted area.'
if (v2Mode.value === 'compose' && v2Task.value === 'outfit') return 'Keep everything the same except the clothes from still B.'
if (v2Mode.value === 'compose' && v2Task.value === 'face_lock') return 'Keep the body and scene from still A. Use the face from still B.'
@@ -3347,17 +3410,19 @@ function currentPresetSnapshot() {
loraStack: [],
settings: {
mode: v2Mode.value,
task: v2Mode.value === 'compose' ? v2Task.value : v2Mode.value === 'refine' ? 'refine' : 'scene',
task: v2Mode.value === 'compose' ? v2Task.value : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene',
negative: v2Negative.value,
snofsModel: clampImageV2Strength(v2SnofsModel.value, IMAGE_V2_SNOFS_MODEL),
snofsClip: clampImageV2Strength(v2SnofsClip.value, IMAGE_V2_SNOFS_CLIP),
consistencyModel: clampImageV2Strength(v2ConsistencyModel.value, IMAGE_V2_CONSISTENCY_MODEL),
consistencyClip: clampImageV2Strength(v2ConsistencyClip.value, IMAGE_V2_CONSISTENCY_CLIP),
consistencyModel: v2Mode.value === 'generate' ? 0 : clampImageV2Strength(v2ConsistencyModel.value, IMAGE_V2_CONSISTENCY_MODEL),
consistencyClip: v2Mode.value === 'generate' ? 0 : clampImageV2Strength(v2ConsistencyClip.value, IMAGE_V2_CONSISTENCY_CLIP),
steps: clampImageSteps(v2Steps.value, IMAGE_V2_STEPS_DEFAULT),
cfg: clampImageCfg(v2Cfg.value, IMAGE_V2_CFG_DEFAULT),
megapixels: clampImageScaleMegapixels(v2Megapixels.value, 1),
megapixels: v2Mode.value === 'generate' ? undefined : clampImageScaleMegapixels(v2Megapixels.value, 1),
turbo: v2Turbo.value === true,
strength: v2Mode.value === 'refine' ? clampImageV2Denoise(v2Denoise.value) : undefined
strength: v2Mode.value === 'refine' ? clampImageV2Denoise(v2Denoise.value) : undefined,
width: v2Mode.value === 'generate' ? v2Width.value : undefined,
height: v2Mode.value === 'generate' ? v2Height.value : undefined
}
}
}
@@ -3396,18 +3461,23 @@ function applyGenerationPreset(preset: GenerationPreset) {
const skipped = preset.loraStack.length - stack.length
if (preset.kind === 'imagev2') {
const settings = preset.settings as ImageV2PresetSettings
v2Mode.value = settings.mode === 'compose' ? 'compose' : settings.mode === 'refine' ? 'refine' : 'edit'
v2Task.value = v2Mode.value === 'compose' ? (settings.task || 'scene') : v2Mode.value === 'refine' ? 'refine' : 'scene'
v2Mode.value = settings.mode === 'compose' ? 'compose' : settings.mode === 'refine' ? 'refine' : settings.mode === 'generate' ? 'generate' : 'edit'
v2Task.value = v2Mode.value === 'compose' ? (settings.task || 'scene') : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene'
if (typeof settings.negative === 'string') v2Negative.value = settings.negative
v2SnofsModel.value = clampImageV2Strength(settings.snofsModel, IMAGE_V2_SNOFS_MODEL)
v2SnofsClip.value = clampImageV2Strength(settings.snofsClip, IMAGE_V2_SNOFS_CLIP)
v2ConsistencyModel.value = clampImageV2Strength(settings.consistencyModel, IMAGE_V2_CONSISTENCY_MODEL)
v2ConsistencyClip.value = clampImageV2Strength(settings.consistencyClip, IMAGE_V2_CONSISTENCY_CLIP)
v2ConsistencyModel.value = v2Mode.value === 'generate' ? 0 : clampImageV2Strength(settings.consistencyModel, IMAGE_V2_CONSISTENCY_MODEL)
v2ConsistencyClip.value = v2Mode.value === 'generate' ? 0 : clampImageV2Strength(settings.consistencyClip, IMAGE_V2_CONSISTENCY_CLIP)
v2Steps.value = clampImageSteps(settings.steps, IMAGE_V2_STEPS_DEFAULT)
v2Cfg.value = clampImageCfg(settings.cfg, IMAGE_V2_CFG_DEFAULT)
v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
v2Turbo.value = settings.turbo === true
if (v2Mode.value === 'refine') v2Denoise.value = clampImageV2Denoise(settings.strength)
if (v2Mode.value === 'generate') {
const size = clampImageV2Size(settings.width, settings.height)
v2Width.value = size.width
v2Height.value = size.height
}
void ensureRefineCanvas()
imageV2PresetId.value = preset.id
imageV2PresetName.value = preset.name
@@ -5196,12 +5266,35 @@ async function editImage() {
}
function setV2Mode(mode: ImageV2Mode) {
const previous = v2Mode.value
v2Mode.value = mode
if (mode === 'edit') v2Task.value = 'scene'
if (mode === 'refine') {
v2Task.value = 'refine'
void ensureRefineCanvas()
}
if (mode === 'generate') {
v2Task.value = 't2i'
v2ConsistencyModel.value = 0
v2ConsistencyClip.value = 0
} else if (previous === 'generate' && v2ConsistencyModel.value === 0 && v2ConsistencyClip.value === 0) {
v2ConsistencyModel.value = IMAGE_V2_CONSISTENCY_MODEL
v2ConsistencyClip.value = IMAGE_V2_CONSISTENCY_CLIP
}
}
function setGenerateSize(aspect: ImageV2GenerateAspect) {
const [width, height] = IMAGE_V2_GENERATE_ASPECTS[aspect]
v2Width.value = width
v2Height.value = height
}
function applyGenerateLook(kind: 'sfw' | 'snofs') {
const look = kind === 'sfw' ? IMAGE_V2_GENERATE_SFW : IMAGE_V2_GENERATE_SNOFS
v2SnofsModel.value = look.snofsModel
v2SnofsClip.value = look.snofsClip
v2ConsistencyModel.value = 0
v2ConsistencyClip.value = 0
}
function applyRefinePreset(kind: 'face' | 'hand' | 'heavy') {
@@ -5238,6 +5331,19 @@ async function ensureRefineCanvas() {
}
}
async function sendToEdit() {
if (!editResultUrl.value) return
studioMode.value = 'editv2'
outputFocus.value = 'edit'
try {
await useEditAsInput()
setV2Mode('edit')
toast('Output is Still A. Write an edit prompt.')
} catch {
toast('Could not send that still to Edit')
}
}
async function sendToRefine() {
if (!editResultUrl.value) return
studioMode.value = 'editv2'
@@ -5268,8 +5374,8 @@ async function editImageV2() {
try {
const body = new FormData()
body.append('mode', v2Mode.value)
body.append('task', v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'compose' ? v2Task.value : 'scene')
body.append('image_a', file.value as File)
body.append('task', v2Mode.value === 'generate' ? 't2i' : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'compose' ? v2Task.value : 'scene')
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 === 'refine') {
const maskBlob = await refinePainter.value?.exportPng()
@@ -5280,15 +5386,20 @@ async function editImageV2() {
body.append('mask', new File([maskBlob], 'refine-mask.png', { type: 'image/png' }))
body.append('strength', String(clampImageV2Denoise(v2Denoise.value)))
}
if (v2Mode.value === 'generate') {
const size = clampImageV2Size(v2Width.value, v2Height.value)
body.append('width', String(size.width))
body.append('height', String(size.height))
}
body.append('prompt', prompt.value.trim())
body.append('negative', v2Negative.value)
body.append('snofs_model', String(clampImageV2Strength(v2SnofsModel.value, IMAGE_V2_SNOFS_MODEL)))
body.append('snofs_clip', String(clampImageV2Strength(v2SnofsClip.value, IMAGE_V2_SNOFS_CLIP)))
body.append('consistency_model', String(clampImageV2Strength(v2ConsistencyModel.value, IMAGE_V2_CONSISTENCY_MODEL)))
body.append('consistency_clip', String(clampImageV2Strength(v2ConsistencyClip.value, IMAGE_V2_CONSISTENCY_CLIP)))
body.append('consistency_model', String(v2Mode.value === 'generate' ? 0 : clampImageV2Strength(v2ConsistencyModel.value, IMAGE_V2_CONSISTENCY_MODEL)))
body.append('consistency_clip', String(v2Mode.value === 'generate' ? 0 : clampImageV2Strength(v2ConsistencyClip.value, IMAGE_V2_CONSISTENCY_CLIP)))
body.append('steps', String(clampImageSteps(v2Steps.value, IMAGE_V2_STEPS_DEFAULT)))
body.append('cfg', String(clampImageCfg(v2Cfg.value, IMAGE_V2_CFG_DEFAULT)))
body.append('megapixels', String(clampImageScaleMegapixels(v2Megapixels.value, 1)))
if (v2Mode.value !== 'generate') body.append('megapixels', String(clampImageScaleMegapixels(v2Megapixels.value, 1)))
body.append('turbo', String(v2Turbo.value === true))
body.append('seed', seedInput.value || 'random')
body.append('folderId', folderId.value)
@@ -5328,7 +5439,7 @@ async function editImageV2() {
editChainLabel.value = ''
editOverallProgress.value = 0
editCompletedChainStep.value = 0
editActiveChainPlan.value = [{ label: v2Mode.value === 'refine' ? 'Refine' : v2Mode.value === 'compose' ? 'Compose' : 'Edit', prompt: prompt.value.trim() }]
editActiveChainPlan.value = [{ label: v2Mode.value === 'generate' ? 'Generate' : v2Mode.value === 'refine' ? 'Refine' : v2Mode.value === 'compose' ? 'Compose' : 'Edit', prompt: prompt.value.trim() }]
startTimer('edit')
editDownloadName.value = downloadName
editJobId.value = started.jobId