Add Image v2 Refine for on-device regional edits.

Mask painter and denoise strength sit beside Edit and Compose. The canned hand/chest prompt helper is gone so that text stays yours.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 22:03:20 -05:00
co-authored by Cursor
parent 21fe2412f9
commit a2895b8054
10 changed files with 825 additions and 56 deletions
+186 -14
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 === 'compose'
? (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.'
: 'Edit: one still plus text. Compose is a separate graph if you need a second still.')
: studioMode === 'edit'
@@ -81,7 +83,16 @@
@drop.prevent="onDrop"
@click="openPicker('main')"
>
<div v-if="preview && !hideInputPreview" class="relative">
<div v-if="studioMode === 'editv2' && v2Mode === 'refine' && preview" class="relative" @click.stop>
<ImageV2MaskPainter
ref="refinePainter"
:src="preview"
:hide-preview="hideInputPreview"
@painted="refineMaskDirty = $event"
/>
<button type="button" class="absolute right-3 top-3 z-10 rounded-full bg-zinc-950/80 px-3 py-1 text-xs" @click.stop="resetImage">Reset</button>
</div>
<div v-else-if="preview && !hideInputPreview" class="relative">
<img :src="preview" alt="Source still" class="max-h-72 w-full rounded-xl object-contain bg-black/40">
<button type="button" class="absolute right-3 top-3 rounded-full bg-zinc-950/80 px-3 py-1 text-xs" @click.stop="resetImage">Reset</button>
</div>
@@ -92,7 +103,7 @@
<button type="button" class="relative mt-3 rounded-full bg-zinc-950/80 px-3 py-1 text-xs" @click.stop="resetImage">Reset</button>
</div>
<div v-else class="flex min-h-44 flex-col items-center justify-center text-center">
<p class="font-medium">{{ studioMode === 'edit' || studioMode === 'editv2' ? (studioMode === 'editv2' ? 'Still A' : 'Image 1') : (textToVideo ? 'Start still · optional' : 'Choose an image') }}</p>
<p class="font-medium">{{ studioMode === 'edit' || studioMode === 'editv2' ? (studioMode === 'editv2' ? (v2Mode === 'refine' ? 'Still A · canvas' : 'Still A') : 'Image 1') : (textToVideo ? 'Start still · optional' : 'Choose an image') }}</p>
<p class="mt-1 text-sm text-zinc-500">{{ textToVideo ? 'Text-to-video does not need a still. Shot 2+ will still extend from the last frame.' : 'Browse the library or upload a new PNG, JPG, or WEBP' }}</p>
</div>
</div>
@@ -102,7 +113,7 @@
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="v2Mode === 'edit' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="v2Mode = 'edit'; v2Task = 'scene'"
@click="setV2Mode('edit')"
>
Edit
</button>
@@ -110,10 +121,18 @@
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="v2Mode === 'compose' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="v2Mode = 'compose'"
@click="setV2Mode('compose')"
>
Compose
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="v2Mode === 'refine' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="setV2Mode('refine')"
>
Refine
</button>
<label v-if="v2Mode === 'compose'" class="ml-auto text-xs">
<span class="mr-2 text-zinc-500">Task</span>
<select
@@ -173,7 +192,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 === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
<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>
<div class="flex flex-wrap items-center gap-3">
<button
v-if="identityPrompting"
@@ -257,6 +276,14 @@
/>
</label>
</div>
<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">
<button type="button" class="rounded-full border border-white/10 px-2.5 py-1 text-xs text-zinc-400 hover:text-white" @click="prompt = IMAGE_V2_REFINE_FACE_PROMPT">
Face prompt
</button>
</div>
</div>
<p v-if="shotScriptMode && studioMode === 'video' && parsedShots.length" class="mt-2 text-xs text-zinc-500 md:hidden">
{{ parsedShots.length }} shot{{ parsedShots.length === 1 ? '' : 's' }} in this script. Open the prompt to scroll through them.
</p>
@@ -671,7 +698,49 @@
/>
</div>
<div v-else-if="studioMode === 'editv2'" class="space-y-3">
<p class="text-xs text-zinc-500">Klein v2 · 9B Base · euler. Edit and Compose are separate graphs. Shares the desktop GPU with video.</p>
<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>
<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>
<div class="flex items-center gap-3">
<input
v-model.number="v2Denoise"
type="range"
:min="IMAGE_V2_DENOISE_MIN"
:max="IMAGE_V2_DENOISE_MAX"
:step="IMAGE_V2_DENOISE_STEP"
class="w-full"
>
<span class="w-12 tabular-nums text-sm text-zinc-200">{{ v2Denoise }}</span>
</div>
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-2.5 py-1 text-xs"
:class="v2Denoise === IMAGE_V2_REFINE_FACE.strength ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="applyRefinePreset('face')"
>
Face {{ IMAGE_V2_REFINE_FACE.strength.toFixed(2) }}
</button>
<button
type="button"
class="rounded-full border px-2.5 py-1 text-xs"
:class="v2Denoise === IMAGE_V2_REFINE_HAND.strength ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="applyRefinePreset('hand')"
>
Hand-chest {{ IMAGE_V2_REFINE_HAND.strength.toFixed(2) }}
</button>
<button
type="button"
class="rounded-full border px-2.5 py-1 text-xs"
:class="v2Denoise === IMAGE_V2_REFINE_HEAVY.strength ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="applyRefinePreset('heavy')"
>
Heavy {{ IMAGE_V2_REFINE_HEAVY.strength.toFixed(2) }}
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">SNOFS Model</span>
@@ -1258,6 +1327,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="sendToRefine"
>
Send to Refine
</button>
</div>
</div>
</div>
@@ -1784,9 +1860,18 @@ import {
IMAGE_V2_CFG_DEFAULT,
IMAGE_V2_CONSISTENCY_CLIP,
IMAGE_V2_CONSISTENCY_MODEL,
IMAGE_V2_DENOISE_DEFAULT,
IMAGE_V2_DENOISE_MAX,
IMAGE_V2_DENOISE_MIN,
IMAGE_V2_DENOISE_STEP,
IMAGE_V2_REFINE_FACE,
IMAGE_V2_REFINE_FACE_PROMPT,
IMAGE_V2_REFINE_HAND,
IMAGE_V2_REFINE_HEAVY,
IMAGE_V2_SNOFS_CLIP,
IMAGE_V2_SNOFS_MODEL,
IMAGE_V2_STEPS_DEFAULT,
clampImageV2Denoise,
clampImageV2Strength,
type ImageV2Mode,
type ImageV2PresetSettings,
@@ -2068,6 +2153,11 @@ const v2Steps = ref(IMAGE_V2_STEPS_DEFAULT)
const v2Cfg = ref(IMAGE_V2_CFG_DEFAULT)
const v2Megapixels = ref(1)
const v2Turbo = ref(false)
const v2Denoise = ref(IMAGE_V2_DENOISE_DEFAULT)
const refineMaskDirty = ref(false)
const refinePainter = ref<{ exportPng: () => Promise<Blob | null>; clear: () => void } | null>(null)
const lastV2StillId = ref('')
const v2JobPending = ref(false)
const editChainStep = ref(1)
const editChainTotal = ref(1)
const editChainLabel = ref('')
@@ -2444,12 +2534,14 @@ const stillBHint = computed(() => {
const editV2Blocked = computed(() => {
if (!file.value || !prompt.value.trim() || !folderId.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 === '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.'
if (!folderId.value) return 'Choose a library folder before generating.'
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image v2 is not configured. Set COMFY_HOST.'
@@ -2458,7 +2550,7 @@ const editV2BlockReason = computed(() => {
})
const editV2SubmitLabel = computed(() => {
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
const label = v2Mode.value === 'compose' ? 'Compose image' : 'Edit image'
const label = v2Mode.value === 'refine' ? 'Refine image' : v2Mode.value === 'compose' ? 'Compose image' : 'Edit image'
return occupied ? `Queue ${label.toLowerCase()}` : label
})
const generateLabel = computed(() => {
@@ -2516,6 +2608,7 @@ const composedIdentityPrompt = computed(() => {
})
const promptPlaceholder = computed(() => {
if (studioMode.value === 'editv2') {
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.'
if (v2Mode.value === 'compose' && v2Task.value === 'identity') return 'Same person as still A. Change the pose and scene.'
@@ -3254,7 +3347,7 @@ function currentPresetSnapshot() {
loraStack: [],
settings: {
mode: v2Mode.value,
task: v2Mode.value === 'compose' ? v2Task.value : 'scene',
task: v2Mode.value === 'compose' ? v2Task.value : v2Mode.value === 'refine' ? 'refine' : 'scene',
negative: v2Negative.value,
snofsModel: clampImageV2Strength(v2SnofsModel.value, IMAGE_V2_SNOFS_MODEL),
snofsClip: clampImageV2Strength(v2SnofsClip.value, IMAGE_V2_SNOFS_CLIP),
@@ -3263,7 +3356,8 @@ function currentPresetSnapshot() {
steps: clampImageSteps(v2Steps.value, IMAGE_V2_STEPS_DEFAULT),
cfg: clampImageCfg(v2Cfg.value, IMAGE_V2_CFG_DEFAULT),
megapixels: clampImageScaleMegapixels(v2Megapixels.value, 1),
turbo: v2Turbo.value === true
turbo: v2Turbo.value === true,
strength: v2Mode.value === 'refine' ? clampImageV2Denoise(v2Denoise.value) : undefined
}
}
}
@@ -3302,8 +3396,8 @@ 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' : 'edit'
v2Task.value = v2Mode.value === 'compose' ? (settings.task || 'scene') : 'scene'
v2Mode.value = settings.mode === 'compose' ? 'compose' : settings.mode === 'refine' ? 'refine' : 'edit'
v2Task.value = v2Mode.value === 'compose' ? (settings.task || 'scene') : v2Mode.value === 'refine' ? 'refine' : '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)
@@ -3313,6 +3407,8 @@ function applyGenerationPreset(preset: GenerationPreset) {
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)
void ensureRefineCanvas()
imageV2PresetId.value = preset.id
imageV2PresetName.value = preset.name
} else if (preset.kind === 'image') {
@@ -3615,6 +3711,8 @@ function resetImage() {
preview.value = ''
imageWidth.value = 0
imageHeight.value = 0
refineMaskDirty.value = false
refinePainter.value?.clear()
}
function clearVideoForm() {
@@ -5097,6 +5195,62 @@ async function editImage() {
}
}
function setV2Mode(mode: ImageV2Mode) {
v2Mode.value = mode
if (mode === 'edit') v2Task.value = 'scene'
if (mode === 'refine') {
v2Task.value = 'refine'
void ensureRefineCanvas()
}
}
function applyRefinePreset(kind: 'face' | 'hand' | 'heavy') {
if (kind === 'face') {
v2Denoise.value = IMAGE_V2_REFINE_FACE.strength
v2SnofsModel.value = IMAGE_V2_REFINE_FACE.snofsModel
v2SnofsClip.value = IMAGE_V2_REFINE_FACE.snofsClip
v2ConsistencyModel.value = IMAGE_V2_REFINE_FACE.consistencyModel
v2ConsistencyClip.value = IMAGE_V2_REFINE_FACE.consistencyClip
if (!prompt.value.trim()) prompt.value = IMAGE_V2_REFINE_FACE_PROMPT
return
}
if (kind === 'hand') {
v2Denoise.value = IMAGE_V2_REFINE_HAND.strength
v2SnofsModel.value = IMAGE_V2_REFINE_HAND.snofsModel
v2SnofsClip.value = IMAGE_V2_REFINE_HAND.snofsClip
v2ConsistencyModel.value = IMAGE_V2_REFINE_HAND.consistencyModel
v2ConsistencyClip.value = IMAGE_V2_REFINE_HAND.consistencyClip
return
}
v2Denoise.value = IMAGE_V2_REFINE_HEAVY.strength
}
async function ensureRefineCanvas() {
if (file.value) return
const stillId = lastV2StillId.value || stillIdFromLibraryUrl(editResultUrl.value)
if (!stillId) return
try {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
if (!blob?.size) return
readFile(new File([blob], 'refine-canvas.png', { type: blob.type || 'image/png' }), { stillId })
} catch {
// leave Still A empty if the last output is gone
}
}
async function sendToRefine() {
if (!editResultUrl.value) return
studioMode.value = 'editv2'
outputFocus.value = 'edit'
try {
await useEditAsInput()
setV2Mode('refine')
toast('Output is Still A. Paint a mask, then refine.')
} catch {
toast('Could not send that still to Refine')
}
}
async function editImageV2() {
if (editV2Blocked.value) {
toast(editV2BlockReason.value || 'Load still A first.')
@@ -5106,13 +5260,26 @@ async function editImageV2() {
toast('Compose requires still B. This will not fall back to one-image generation.')
return
}
if (v2Mode.value === 'refine' && !refineMaskDirty.value) {
toast('Paint a mask on Still A first. Refine will not fall back to Edit.')
return
}
const hideOut = hideThumbnail.value
try {
const body = new FormData()
body.append('mode', v2Mode.value)
body.append('task', v2Mode.value === 'compose' ? v2Task.value : 'scene')
body.append('task', v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'compose' ? v2Task.value : 'scene')
body.append('image_a', file.value as File)
if (v2Mode.value === 'compose' && editRefFile.value) body.append('image_b', editRefFile.value)
if (v2Mode.value === 'refine') {
const maskBlob = await refinePainter.value?.exportPng()
if (!maskBlob) {
toast('Paint a mask on Still A first. Refine will not fall back to Edit.')
return
}
body.append('mask', new File([maskBlob], 'refine-mask.png', { type: 'image/png' }))
body.append('strength', String(clampImageV2Denoise(v2Denoise.value)))
}
body.append('prompt', prompt.value.trim())
body.append('negative', v2Negative.value)
body.append('snofs_model', String(clampImageV2Strength(v2SnofsModel.value, IMAGE_V2_SNOFS_MODEL)))
@@ -5161,12 +5328,13 @@ async function editImageV2() {
editChainLabel.value = ''
editOverallProgress.value = 0
editCompletedChainStep.value = 0
editActiveChainPlan.value = [{ label: v2Mode.value === 'compose' ? 'Compose' : 'Edit', prompt: prompt.value.trim() }]
editActiveChainPlan.value = [{ label: v2Mode.value === 'refine' ? 'Refine' : v2Mode.value === 'compose' ? 'Compose' : 'Edit', prompt: prompt.value.trim() }]
startTimer('edit')
editDownloadName.value = downloadName
editJobId.value = started.jobId
outputStudioJobId.value = started.studioJobId || ''
persistActiveJob('edit', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
v2JobPending.value = true
listen('edit', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
void pollComfyHealth()
void refreshStudioQueue()
@@ -5458,6 +5626,7 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
if (payload.stillId && !locked) {
editResultUrl.value = `/api/library/stills/${payload.stillId}`
if (studioMode.value === 'editv2' || v2JobPending.value) lastV2StillId.value = payload.stillId
editLockedSave.value = false
if (payload.hideThumbnail || hidden) {
concealEditOutput.value = true
@@ -5501,6 +5670,7 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
editStatusMessage.value = 'Saved to the locked folder. Unlock it to view.'
} else {
editResultUrl.value = `/api/library/stills/${payload.stillId}`
if (studioMode.value === 'editv2' || v2JobPending.value) lastV2StillId.value = payload.stillId
concealEditOutput.value = Boolean(payload.hideThumbnail || hidden)
editAwaitingReveal.value = concealEditOutput.value
editLockedSave.value = false
@@ -5509,6 +5679,7 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
}
editBusy.value = false
editStatusBusy.value = false
v2JobPending.value = false
stopTimer('edit')
stopListen('edit')
clearActiveJob('edit')
@@ -5521,6 +5692,7 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
if (failed) {
editSettledUi = true
editBusy.value = false
v2JobPending.value = false
editStatusBusy.value = false
stopTimer('edit')
stopListen('edit')