Add Image v2 as a sibling of Image edit, with separate Klein graphs.

Keep the v1 Image tab on its dual-branch template. The host agent follows Desktop's live port so Coolify stays on :8198.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 21:22:37 -05:00
co-authored by Cursor
parent 55f8cb1def
commit 21fe2412f9
18 changed files with 1678 additions and 67 deletions
+345 -32
View File
@@ -54,9 +54,21 @@
>
Image edit
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'editv2' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'editv2'; outputFocus = 'edit'"
>
Image v2
</button>
</div>
<h2 class="font-display text-2xl font-bold">Input</h2>
<p class="text-sm text-zinc-400">{{ studioMode === 'edit'
<p class="text-sm text-zinc-400">{{ studioMode === 'editv2'
? (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'
? 'Drop the still to edit. An optional second still applies image 2 onto image 1. Files leave the desktop after save.'
: 'Drop a still, describe the motion, then send the job. Generate stays available — a long shot list takes one queue slot, and you can line up more while it runs.' }}</p>
</div>
@@ -80,14 +92,45 @@
<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' ? 'Image 1' : (textToVideo ? 'Start still · optional' : 'Choose an image') }}</p>
<p class="font-medium">{{ studioMode === 'edit' || studioMode === 'editv2' ? (studioMode === 'editv2' ? '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>
<div v-if="studioMode === 'edit'" class="rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 p-4">
<div v-if="studioMode === 'editv2'" class="flex flex-wrap gap-2">
<button
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'"
>
Edit
</button>
<button
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'"
>
Compose
</button>
<label v-if="v2Mode === 'compose'" class="ml-auto text-xs">
<span class="mr-2 text-zinc-500">Task</span>
<select
v-model="v2Task"
class="rounded-lg border border-white/10 bg-zinc-950 px-2 py-1 text-xs text-zinc-300 outline-none ring-amber-300/40 focus:ring-2"
>
<option value="scene">scene</option>
<option value="identity">identity</option>
<option value="outfit">outfit</option>
<option value="face_lock">face_lock</option>
</select>
</label>
</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 class="mb-2 flex items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-300">Image 2 · optional</p>
<p class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' ? 'Still B · required' : 'Image 2 · optional' }}</p>
<button
v-if="editRefFile"
type="button"
@@ -116,19 +159,21 @@
class="max-h-40 w-full object-contain bg-black/40"
>
<span v-else class="flex min-h-28 items-center justify-center px-3 py-6 text-center text-sm text-zinc-500">
Apply this still onto image 1
{{ studioMode === 'editv2' ? stillBHint : 'Apply this still onto image 1' }}
</span>
</button>
<p class="mt-2 text-xs text-zinc-500">
{{ editRefFile
? 'Two-image branch on. Image 2 is applied onto image 1.'
: 'One-image branch on. Add a second still to transfer details from image 2.' }}
{{ studioMode === 'editv2'
? (editRefFile ? stillBHint : 'Compose will reject this job until still B is loaded.')
: (editRefFile
? 'Two-image branch on. Image 2 is applied onto image 1.'
: 'One-image branch on. Add a second still to transfer details from image 2.') }}
</p>
</div>
<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' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
<label class="text-sm font-medium text-zinc-300">{{ studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
<div class="flex flex-wrap items-center gap-3">
<button
v-if="identityPrompting"
@@ -175,7 +220,7 @@
</p>
<div
class="grid gap-3"
:class="studioMode === 'edit' ? 'md:grid-cols-2' : ''"
:class="studioMode === 'edit' || studioMode === 'editv2' ? 'md:grid-cols-2' : ''"
>
<div class="min-w-0">
<textarea
@@ -194,9 +239,17 @@
<span class="mt-2 inline-block text-xs text-amber-200">{{ prompt.trim() ? 'View / edit prompt' : 'Write prompt' }}</span>
</button>
</div>
<label v-if="studioMode === 'edit'" class="block min-w-0 text-sm">
<label v-if="studioMode === 'edit' || studioMode === 'editv2'" class="block min-w-0 text-sm">
<span class="mb-2 block font-medium text-zinc-300">Negative prompt</span>
<textarea
v-if="studioMode === 'editv2'"
v-model="v2Negative"
rows="2"
class="min-h-[4.5rem] w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:h-[calc(100%-1.75rem)] md:min-h-[8.5rem]"
placeholder="What to avoid…"
/>
<textarea
v-else
v-model="editNegative"
rows="2"
class="min-h-[4.5rem] w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:h-[calc(100%-1.75rem)] md:min-h-[8.5rem]"
@@ -617,7 +670,62 @@
@remove="deleteGenerationPreset"
/>
</div>
<div v-else class="space-y-3">
<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>
<div class="grid grid-cols-2 gap-3">
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">SNOFS Model</span>
<input v-model.number="v2SnofsModel" 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">
<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">
<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">
<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>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Steps</span>
<input v-model.number="v2Steps" type="number" :min="IMAGE_STEPS_MIN" :max="IMAGE_STEPS_MAX" step="1" :disabled="v2Turbo" 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 disabled:opacity-50">
</label>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">CFG</span>
<input v-model.number="v2Cfg" type="number" :min="IMAGE_CFG_MIN" :max="IMAGE_CFG_MAX" :step="IMAGE_CFG_STEP" :disabled="v2Turbo" 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 disabled:opacity-50">
</label>
<label class="block text-sm">
<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">
<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>
</div>
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
<input v-model="v2Turbo" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-4" />
</span>
Turbo · 8 steps, CFG 1
</label>
<div class="max-w-md">
<GenerationPresetBar
:presets="imageV2GenerationPresets"
v-model:selected-id="imageV2PresetId"
v-model:name="imageV2PresetName"
@load="loadGenerationPreset"
@save="saveGenerationPreset"
@remove="deleteGenerationPreset"
/>
</div>
</div>
<div v-else-if="studioMode === 'edit'" class="space-y-3">
<p class="text-xs text-zinc-500">Flux.2 Klein 9B. Shares the desktop GPU with video.</p>
<div class="max-w-md space-y-2">
<LoraSelect v-model="imageLoraStack" :names="imageLoraOptions" label="LoRA" empty-label="None" compact />
@@ -795,10 +903,10 @@
<button
type="button"
class="flex-1 rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="studioMode === 'edit' ? editBlocked : videoGenerateDisabled"
@click="studioMode === 'edit' ? editImage() : generate()"
:disabled="studioMode === 'editv2' ? editV2Blocked : (studioMode === 'edit' ? editBlocked : videoGenerateDisabled)"
@click="studioMode === 'editv2' ? editImageV2() : (studioMode === 'edit' ? editImage() : generate())"
>
{{ studioMode === 'edit' ? editSubmitLabel : generateLabel }}
{{ studioMode === 'editv2' ? editV2SubmitLabel : (studioMode === 'edit' ? editSubmitLabel : generateLabel) }}
</button>
<button
v-if="videoBusy || editBusy"
@@ -810,9 +918,10 @@
</button>
</div>
<p v-if="studioMode === 'video' && videoBusy" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
<p v-else-if="studioMode === 'edit' && (videoBusy || editBusy)" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
<p v-else-if="(studioMode === 'edit' || studioMode === 'editv2') && (videoBusy || editBusy)" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
<p v-if="studioMode === 'video' && videoBlockReason" class="text-xs text-zinc-400">{{ videoBlockReason }}</p>
<p v-if="studioMode === 'edit' && editBlockReason" class="text-xs text-zinc-400">{{ editBlockReason }}</p>
<p v-if="studioMode === 'editv2' && editV2BlockReason" class="text-xs text-zinc-400">{{ editV2BlockReason }}</p>
<div v-if="studioJobs.length" class="rounded-2xl border border-white/10 bg-zinc-950/40 p-4">
<div class="flex items-start justify-between gap-3">
@@ -836,7 +945,7 @@
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-zinc-100">{{ job.name }}</p>
<p class="mt-0.5 text-xs text-zinc-500">
<span v-if="job.kind === 'edit'" class="mr-1 text-amber-200/80">{{ (job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE' }} ·</span>
<span v-if="job.kind === 'edit'" class="mr-1 text-amber-200/80">{{ job.imagePipeline === 'v2' ? 'IMAGE V2' : ((job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE') }} ·</span>
{{ studioJobStatusLabel(job) }}
· {{ studioJobShotLine(job) }}
</p>
@@ -1545,7 +1654,7 @@
<PromptDrawer
:open="promptDrawerOpen"
v-model:text="prompt"
:title="studioMode === 'edit' ? 'Edit prompt' : (shotScriptMode ? 'Shot script' : 'Prompt')"
:title="studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : (shotScriptMode ? 'Shot script' : 'Prompt')"
:placeholder="promptPlaceholder"
@update:open="promptDrawerOpen = $event"
/>
@@ -1671,6 +1780,18 @@ import {
VIDEO_CFG_STEP,
type GenerationPreset
} from '~/utils/generationPresets'
import {
IMAGE_V2_CFG_DEFAULT,
IMAGE_V2_CONSISTENCY_CLIP,
IMAGE_V2_CONSISTENCY_MODEL,
IMAGE_V2_SNOFS_CLIP,
IMAGE_V2_SNOFS_MODEL,
IMAGE_V2_STEPS_DEFAULT,
clampImageV2Strength,
type ImageV2Mode,
type ImageV2PresetSettings,
type ImageV2Task
} from '~/utils/imageV2'
import {
coerceVideoWorkflow,
composeVideoWorkflow,
@@ -1863,10 +1984,13 @@ const shotLoraStacks = ref<Record<number, LoraStackItem[]>>({})
const extendLoraStack = ref<LoraStackItem[]>([])
const videoGenerationPresets = ref<GenerationPreset[]>([])
const imageGenerationPresets = ref<GenerationPreset[]>([])
const imageV2GenerationPresets = ref<GenerationPreset[]>([])
const videoPresetId = ref('')
const imagePresetId = ref('')
const imageV2PresetId = ref('')
const videoPresetName = ref('')
const imagePresetName = ref('')
const imageV2PresetName = ref('')
const CFG_MIN = VIDEO_CFG_MIN
const CFG_MAX = VIDEO_CFG_MAX
const CFG_STEP = VIDEO_CFG_STEP
@@ -1933,6 +2057,17 @@ const editScaleToTotalPixels = ref(false)
const editScaleMegapixels = ref(1)
const editRefFile = ref<File | null>(null)
const editRefPreview = ref('')
const v2Mode = ref<ImageV2Mode>('edit')
const v2Task = ref<ImageV2Task>('scene')
const v2Negative = ref('')
const v2SnofsModel = ref(IMAGE_V2_SNOFS_MODEL)
const v2SnofsClip = ref(IMAGE_V2_SNOFS_CLIP)
const v2ConsistencyModel = ref(IMAGE_V2_CONSISTENCY_MODEL)
const v2ConsistencyClip = ref(IMAGE_V2_CONSISTENCY_CLIP)
const v2Steps = ref(IMAGE_V2_STEPS_DEFAULT)
const v2Cfg = ref(IMAGE_V2_CFG_DEFAULT)
const v2Megapixels = ref(1)
const v2Turbo = ref(false)
const editChainStep = ref(1)
const editChainTotal = ref(1)
const editChainLabel = ref('')
@@ -2010,6 +2145,7 @@ const studioJobs = ref<Array<{
lastError?: string
completedCount?: number
pendingCount?: number
imagePipeline?: string
}>>([])
const outputStudioJobId = ref('')
const videoAwaitingBeast = ref(false)
@@ -2044,8 +2180,8 @@ const LOCKS_STORE = 'aigen-global-locks'
const REFS_STORE = 'aigen-permanence-refs'
const pickerHeading = computed(() => {
if (pickerKind.value === 'permanence') return 'Choose a still to name'
if (pickerSlot.value === 'main') return studioMode.value === 'edit' ? 'Choose image 1' : 'Choose an image'
if (pickerSlot.value === 'editRef') return 'Choose image 2'
if (pickerSlot.value === 'main') return studioMode.value === 'edit' || studioMode.value === 'editv2' ? (studioMode.value === 'editv2' ? 'Choose still A' : 'Choose image 1') : 'Choose an image'
if (pickerSlot.value === 'editRef') return studioMode.value === 'editv2' ? 'Choose still B' : 'Choose image 2'
return `Choose Picture ${Number(pickerSlot.value) + 2}`
})
const permanencePicker = computed(() => pickerKind.value === 'permanence')
@@ -2299,6 +2435,32 @@ const editBlockReason = computed(() => {
if (!comfyOk.value && !imageComfyOk.value) return 'Desktop GPU is offline.'
return ''
})
const stillBHint = computed(() => {
if (v2Task.value === 'outfit') return 'Clothing only from still B. Person, body, pose, and background stay on still A.'
if (v2Task.value === 'face_lock') return 'Exact face from still B. Body, pose, and scene stay on still A.'
if (v2Task.value === 'identity') return 'Still B reinforces the face. Still A is the person.'
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 (v2Mode.value === 'compose' && !editRefFile.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 (!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.'
if (!comfyOk.value && !imageComfyOk.value) return 'Desktop GPU is offline.'
return ''
})
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'
return occupied ? `Queue ${label.toLowerCase()}` : label
})
const generateLabel = computed(() => {
const n = queuedExtensionCount.value
if (videoBusy.value) return n ? `Queue ${n + 1} shots` : 'Queue job'
@@ -2353,6 +2515,12 @@ const composedIdentityPrompt = computed(() => {
return buildIdentityPrompt(identityActionPrompt.value, extraIdentityPictures.value)
})
const promptPlaceholder = computed(() => {
if (studioMode.value === 'editv2') {
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.'
return 'Make the lighting warmer. Keep the face unchanged.'
}
if (studioMode.value === 'edit') {
return editRefFile.value
? 'Apply the dress in image 2 to the person in image 1. Preserve the exact features, proportions, pose, and setting of image 1.'
@@ -3064,18 +3232,41 @@ function applyLoraSelection(stack?: unknown, shotStacks?: Array<unknown>) {
shotLoraStacks.value = next
}
const currentPresetKind = computed(() => studioMode.value === 'edit' ? 'image' : 'video')
const currentPresetKind = computed(() => (
studioMode.value === 'editv2' ? 'imagev2' : studioMode.value === 'edit' ? 'image' : 'video'
))
async function loadGenerationPresets() {
const [video, image] = await Promise.all([
const [video, image, imagev2] = await Promise.all([
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'video' } }).catch(() => ({ presets: [] })),
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'image' } }).catch(() => ({ presets: [] }))
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'image' } }).catch(() => ({ presets: [] })),
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'imagev2' } }).catch(() => ({ presets: [] }))
])
videoGenerationPresets.value = video.presets || []
imageGenerationPresets.value = image.presets || []
imageV2GenerationPresets.value = imagev2.presets || []
}
function currentPresetSnapshot() {
if (studioMode.value === 'editv2') {
return {
kind: 'imagev2' as const,
loraStack: [],
settings: {
mode: v2Mode.value,
task: v2Mode.value === 'compose' ? v2Task.value : '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),
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
}
}
}
if (studioMode.value === 'edit') {
return {
kind: 'image' as const,
@@ -3109,7 +3300,22 @@ function currentPresetSnapshot() {
function applyGenerationPreset(preset: GenerationPreset) {
const stack = filterLoraStackForStudio(preset.loraStack, ltxEnabled.value)
const skipped = preset.loraStack.length - stack.length
if (preset.kind === 'image') {
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'
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)
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
imageV2PresetId.value = preset.id
imageV2PresetName.value = preset.name
} else if (preset.kind === 'image') {
imageLoraStack.value = stack
const settings = preset.settings as {
steps?: number
@@ -3168,20 +3374,31 @@ function applyGenerationPreset(preset: GenerationPreset) {
})
}
function currentPresetList() {
if (currentPresetKind.value === 'imagev2') return imageV2GenerationPresets.value
if (currentPresetKind.value === 'image') return imageGenerationPresets.value
return videoGenerationPresets.value
}
function loadGenerationPreset(id: string) {
const list = currentPresetKind.value === 'image' ? imageGenerationPresets.value : videoGenerationPresets.value
const preset = list.find(item => item.id === id)
const preset = currentPresetList().find(item => item.id === id)
if (!preset) return
applyGenerationPreset(preset)
}
async function saveGenerationPreset() {
const name = (currentPresetKind.value === 'image' ? imagePresetName.value : videoPresetName.value).trim()
const name = (
currentPresetKind.value === 'imagev2'
? imageV2PresetName.value
: currentPresetKind.value === 'image'
? imagePresetName.value
: videoPresetName.value
).trim()
if (!name) {
toast('Name the preset before saving.')
return
}
const list = currentPresetKind.value === 'image' ? imageGenerationPresets.value : videoGenerationPresets.value
const list = currentPresetList()
const existing = list.find(item => item.name.toLowerCase() === name.toLowerCase())
if (existing && !window.confirm(`Overwrite preset “${existing.name}”?`)) return
try {
@@ -3190,7 +3407,11 @@ async function saveGenerationPreset() {
method: 'POST',
body
})
if (saved.preset.kind === 'image') {
if (saved.preset.kind === 'imagev2') {
imageV2GenerationPresets.value = saved.presets
imageV2PresetId.value = saved.preset.id
imageV2PresetName.value = saved.preset.name
} else if (saved.preset.kind === 'image') {
imageGenerationPresets.value = saved.presets
imagePresetId.value = saved.preset.id
imagePresetName.value = saved.preset.name
@@ -3206,8 +3427,12 @@ async function saveGenerationPreset() {
}
async function deleteGenerationPreset() {
const id = currentPresetKind.value === 'image' ? imagePresetId.value : videoPresetId.value
const list = currentPresetKind.value === 'image' ? imageGenerationPresets.value : videoGenerationPresets.value
const id = currentPresetKind.value === 'imagev2'
? imageV2PresetId.value
: currentPresetKind.value === 'image'
? imagePresetId.value
: videoPresetId.value
const list = currentPresetList()
const preset = list.find(item => item.id === id)
if (!id || !preset) return
if (!window.confirm(`Delete preset “${preset.name}”?`)) return
@@ -3216,7 +3441,10 @@ async function deleteGenerationPreset() {
method: 'DELETE',
query: { kind: currentPresetKind.value }
})
if (currentPresetKind.value === 'image') {
if (currentPresetKind.value === 'imagev2') {
imageV2GenerationPresets.value = saved.presets
imageV2PresetId.value = ''
} else if (currentPresetKind.value === 'image') {
imageGenerationPresets.value = saved.presets
imagePresetId.value = ''
} else {
@@ -4869,6 +5097,91 @@ async function editImage() {
}
}
async function editImageV2() {
if (editV2Blocked.value) {
toast(editV2BlockReason.value || 'Load still A first.')
return
}
if (v2Mode.value === 'compose' && !editRefFile.value) {
toast('Compose requires still B. This will not fall back to one-image generation.')
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('image_a', file.value as File)
if (v2Mode.value === 'compose' && editRefFile.value) body.append('image_b', editRefFile.value)
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('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)))
body.append('turbo', String(v2Turbo.value === true))
body.append('seed', seedInput.value || 'random')
body.append('folderId', folderId.value)
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
body.append('name', clipName.value.trim())
const downloadName = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-v2')
const started = await $fetch<{
jobId: string
studioJobId?: string
queued?: boolean
steps: number
hideThumbnail?: boolean
folderLocked?: boolean
}>('/api/v2/generate', {
method: 'POST',
body
})
if (started.queued) {
toast('Queued Klein v2 behind the current job.')
await refreshStudioQueue()
return
}
editStatusBusy.value = false
editBusy.value = true
stopListen('edit')
editSettledUi = false
editProgress.value = 2
editResultUrl.value = ''
editAwaitingReveal.value = false
concealEditOutput.value = hideOut
editLockedSave.value = false
editStatusMessage.value = 'Checking Beast ComfyUI...'
editMaxStep.value = started.steps
editChainStep.value = 1
editChainTotal.value = 1
editChainLabel.value = ''
editOverallProgress.value = 0
editCompletedChainStep.value = 0
editActiveChainPlan.value = [{ label: 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)
listen('edit', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
void pollComfyHealth()
void refreshStudioQueue()
} catch (error: any) {
editSettledUi = true
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image v2 failed'
editStatusMessage.value = message
toast(message)
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
void pollComfyHealth()
}
}
async function useEditAsInput() {
if (!editResultUrl.value) return
try {
+5 -1
View File
@@ -457,6 +457,7 @@ type StudioJobRow = {
shots?: Queue | null
plannedShots?: { prompt: string; duration: number; loraName?: string; loraStack?: LoraStackItem[] }[]
completedCount?: number
imagePipeline?: string
}
type PendingRemove = {
@@ -759,7 +760,10 @@ function listenJob(job: StudioJobRow) {
}
function jobKindLabel(job: StudioJobRow) {
if (job.kind === 'edit') return (job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE'
if (job.kind === 'edit') {
if (job.imagePipeline === 'v2') return 'IMAGE V2'
return (job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE'
}
return 'Job'
}