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:
+345
-32
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user