Overhaul Studio controls and add image batch from one still.

Video and Image pickers, thumbwheels, aspect icons, and a Scale to MP paddle; extra prompts default to batch so each run starts from the original image.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-30 23:16:19 -05:00
co-authored by Cursor
parent 8df265cbf3
commit 755aaeede8
17 changed files with 791 additions and 233 deletions
+189 -148
View File
@@ -12,30 +12,34 @@
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<section id="studio-input" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
<div>
<div class="mb-3 flex flex-wrap gap-2">
<div class="mb-4 grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'video' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'video'; outputFocus = 'video'"
class="rounded-3xl border px-4 py-4 text-left transition"
:class="studioMode === 'video' ? 'border-amber-300 bg-amber-400/10 text-amber-50' : 'border-white/10 text-zinc-400 hover:border-white/20 hover:text-zinc-200'"
@click="selectStudioKind('video')"
>
Video
<svg class="h-10 w-10" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<rect x="4" y="10" width="24" height="20" rx="3" stroke="currentColor" stroke-width="2" />
<path d="M28 16 36 12v16l-8-4V16Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
<circle cx="12" cy="20" r="2" fill="currentColor" />
</svg>
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Video</span>
<span class="mt-0.5 block text-xs text-zinc-500">MiniMax H3 · LTX</span>
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'edit' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'edit'; outputFocus = 'edit'"
class="rounded-3xl border px-4 py-4 text-left transition"
:class="imageStudio ? 'border-amber-300 bg-amber-400/10 text-amber-50' : 'border-white/10 text-zinc-400 hover:border-white/20 hover:text-zinc-200'"
@click="selectStudioKind('image')"
>
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
<svg class="h-10 w-10" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<rect x="6" y="8" width="28" height="24" rx="3" stroke="currentColor" stroke-width="2" />
<path d="m10 26 7-8 6 7 3-3 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="15" cy="15" r="2.5" fill="currentColor" />
</svg>
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Image</span>
<span class="mt-0.5 block text-xs text-zinc-500">Flux · Krea</span>
</button>
</div>
<h2 class="font-display text-2xl font-bold">Input</h2>
@@ -335,13 +339,33 @@
</div>
</div>
<div v-if="studioMode === 'edit' || v2PassChaining" class="mt-3 space-y-3">
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="imagePassMode === 'batch' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="imagePassMode = 'batch'"
>
<span class="block font-semibold">Batch</span>
<span class="text-[11px] leading-snug text-zinc-400">Each prompt from the start still</span>
</button>
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="imagePassMode === 'chain' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="imagePassMode = 'chain'"
>
<span class="block font-semibold">Chain</span>
<span class="text-[11px] leading-snug text-zinc-400">Each pass from the last output</span>
</button>
</div>
<div
v-for="(item, index) in editPassQueue"
:key="item.id"
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
>
<div class="mb-3 flex items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-200">Pass {{ index + 2 }}</p>
<p class="text-sm font-medium text-zinc-200">{{ imagePassMode === 'batch' ? 'Prompt' : 'Pass' }} {{ index + 2 }}</p>
<button
type="button"
class="text-xs text-red-300 hover:text-red-200"
@@ -615,7 +639,7 @@
</div>
<p class="mt-2 text-xs text-zinc-500">{{ videoModelHint }}</p>
</div>
<div v-if="videoEngine === 'minimax' && videoStart === 'still'">
<div v-if="showLegacyMinimaxV1 && videoEngine === 'minimax' && videoStart === 'still'">
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">MiniMax graph</p>
<div class="grid grid-cols-2 gap-2">
<button
@@ -751,36 +775,20 @@
<p v-if="v2Engine === 'krea'" class="mt-1 text-[11px] text-zinc-500">Flux LoRAs stay on the Flux engine. Switch back to restore that chain.</p>
</div>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio</p>
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button
v-for="option in imageAspects"
:key="option.id"
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="setImageAspect(option.id)"
>
<span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.hint }}</span>
</button>
</div>
<AspectRatioPick :model-value="aspect" heading="Aspect ratio" :options="imageAspects" @update:model-value="setImageAspect" />
<p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p>
</div>
<div v-if="v2Mode === 'refine' || (v2Engine === 'krea' && v2Mode !== 'generate')" 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>
<Thumbwheel
v-model="v2Denoise"
:min="IMAGE_V2_DENOISE_MIN"
:max="IMAGE_V2_DENOISE_MAX"
:step="IMAGE_V2_DENOISE_STEP"
:digits="2"
compact
/>
</label>
<div v-if="v2Mode === 'refine'" class="flex flex-wrap gap-2">
<button
@@ -809,24 +817,46 @@
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<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 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>
<div class="flex flex-wrap items-start gap-x-4 gap-y-3">
<Thumbwheel
v-model="v2Steps"
label="Steps"
:min="IMAGE_STEPS_MIN"
:max="IMAGE_STEPS_MAX"
:step="1"
:fine-step="1"
:coarse-step="5"
:digits="0"
compact
:disabled="v2Turbo"
/>
<Thumbwheel
v-model="v2Cfg"
label="CFG"
:min="IMAGE_CFG_MIN"
:max="IMAGE_CFG_MAX"
:step="IMAGE_CFG_STEP"
compact
:disabled="v2Turbo"
/>
<template v-if="v2Mode !== 'generate'">
<LampToggle :model-value="v2ScaleOn" label="Scale to MP" @update:model-value="setV2ScaleOn" />
<Thumbwheel
v-model="v2Megapixels"
aria-label="Megapixels"
:min="IMAGE_SCALE_MP_MIN"
:max="IMAGE_SCALE_MP_MAX"
:step="IMAGE_SCALE_MP_STEP"
:digits="2"
compact
:disabled="!v2ScaleOn"
/>
</template>
</div>
<label class="block max-w-xs 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 v-if="v2Engine !== 'krea'" 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">
@@ -847,22 +877,9 @@
</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>
<p class="text-xs text-zinc-500">Legacy Klein v1 still. New Image work uses Flux / Krea above. This graph stays for older library stills.</p>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio</p>
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button
v-for="option in imageAspects"
:key="'v1-' + option.id"
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="setImageAspect(option.id)"
>
<span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.hint }}</span>
</button>
</div>
<AspectRatioPick :model-value="aspect" heading="Aspect ratio" :options="imageAspects" @update:model-value="setImageAspect" />
<p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p>
</div>
<div class="max-w-md space-y-2">
@@ -932,20 +949,7 @@
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
<div class="mt-4 space-y-4">
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio / resolution</p>
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button
v-for="option in aspects"
:key="option.id"
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="aspect = option.id"
>
<span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.id === 'auto' ? autoHint : option.hint }}</span>
</button>
</div>
<AspectRatioPick v-model="aspect" heading="Aspect ratio / resolution" :options="videoAspects" />
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? (file ? ' · matching the still' : ' · 16:9 until a still is loaded') : '' }}</p>
</div>
<div>
@@ -956,30 +960,15 @@
</div>
</div>
<div class="text-sm">
<span class="mb-1 block text-zinc-400">CFG / Guidance scale</span>
<div class="flex items-center gap-3">
<input
class="aigen-slider min-w-0 flex-1"
type="range"
:min="CFG_MIN"
:max="CFG_MAX"
:step="CFG_STEP"
:value="cfg"
:style="{ background: `linear-gradient(to right, #fbbf24 ${cfgPct}%, #27272a ${cfgPct}%)` }"
aria-label="CFG guidance scale"
@input="onCfgSlider"
>
<input
v-model.number="cfg"
type="number"
:min="CFG_MIN"
:max="CFG_MAX"
:step="CFG_STEP"
class="w-24 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
@change="onCfgNumber"
>
</div>
<p class="mt-1 text-[11px] text-zinc-500">{{ ltxVideo ? 'Default 3.5 for PinkCherry' : (turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity') }}. Type 0–20 (0.1 steps); the box is the source of truth.</p>
<Thumbwheel
v-model="cfg"
label="CFG / Guidance scale"
:min="CFG_MIN"
:max="CFG_MAX"
:step="CFG_STEP"
@update:model-value="cfgTouched = true"
/>
<p class="mt-1 text-[11px] text-zinc-500">{{ ltxVideo ? 'Default 3.5 for PinkCherry' : (turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity') }}. Hover and scroll ±0.1, Shift-scroll ±1. Drag the wheel up and down.</p>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="text-sm">
@@ -996,7 +985,7 @@
label="Duration"
:hint="shotScriptMode
? 'This length applies to the initial shot and every extension in the script.'
: 'Type any length, use the slider, or drag up/down in the box.'"
: 'Type any length, or hover the wheel and scroll. Shift-scroll jumps by 1s. Drag up and down.'"
/>
</div>
<label class="text-sm">
@@ -1326,7 +1315,7 @@
<DurationField
v-model="extendDuration"
label="Extension duration"
hint="Type any length, use the slider, or drag up/down in the box."
hint="Type any length, or hover the wheel and scroll. Shift-scroll jumps by 1s."
/>
<LoraSelect v-model="extendLoraStack" :names="videoLoraOptions" label="LoRA" empty-label="Same as clip / none" compact :clip-strength="false" />
<button
@@ -2007,11 +1996,13 @@ import {
IMAGE_CFG_MAX,
IMAGE_CFG_MIN,
IMAGE_CFG_STEP,
IMAGE_SCALE_MP_DEFAULT,
IMAGE_SCALE_MP_MAX,
IMAGE_SCALE_MP_MIN,
IMAGE_SCALE_MP_STEP,
IMAGE_STEPS_MAX,
IMAGE_STEPS_MIN,
type ImagePassMode,
VIDEO_CFG_MAX,
VIDEO_CFG_MIN,
VIDEO_CFG_STEP,
@@ -2271,6 +2262,7 @@ const editPassesBeforeRecommend = ref<QueuedEditPass[]>([])
const shotScriptBeforeRecommend = ref(false)
const extensionQueue = ref<QueuedExtension[]>([])
const editPassQueue = ref<QueuedEditPass[]>([])
const imagePassMode = ref<ImagePassMode>('batch')
const clipName = ref('')
const videoDownloadName = ref('output')
const editDownloadName = ref('edited')
@@ -2317,6 +2309,18 @@ const videoBusy = ref(false)
const editBusy = ref(false)
const studioMode = ref('video')
const outputFocus = ref<'video' | 'edit'>('video')
const showLegacyMinimaxV1 = false
const imageStudio = computed(() => studioMode.value === 'edit' || studioMode.value === 'editv2')
function selectStudioKind(kind: 'video' | 'image') {
if (kind === 'video') {
studioMode.value = 'video'
outputFocus.value = 'video'
return
}
studioMode.value = 'editv2'
outputFocus.value = 'edit'
}
const imageComfyOk = ref(false)
const imageComfyBusy = ref(false)
const imageComfyConfigured = ref(false)
@@ -2367,7 +2371,8 @@ const v2Task = ref<ImageV2Task>('scene')
const v2Negative = ref('')
const v2Steps = ref(IMAGE_V2_STEPS_DEFAULT)
const v2Cfg = ref(IMAGE_V2_CFG_DEFAULT)
const v2Megapixels = ref(1)
const v2Megapixels = ref(IMAGE_SCALE_MP_DEFAULT)
const v2ScaleOn = ref(true)
const v2Turbo = ref(false)
const v2Width = ref(IMAGE_V2_GENERATE_WIDTH)
const v2Height = ref(IMAGE_V2_GENERATE_HEIGHT)
@@ -2501,7 +2506,7 @@ const pendingLibraryDelete = ref<PendingLibraryDelete | null>(null)
const libraryDeleteBusy = ref(false)
const videoEngine = ref<VideoEngineId>('minimax')
const videoStart = ref<VideoStartId>('still')
const minimaxGraph = ref<MiniMaxGraphId>('v1')
const minimaxGraph = ref<MiniMaxGraphId>('v2')
const videoWorkflow = computed(() => composeVideoWorkflow(videoEngine.value, videoStart.value, minimaxGraph.value))
const textToVideo = computed(() => isTextToVideo(videoWorkflow.value))
const ltxVideo = computed(() => isLtxWorkflow(videoWorkflow.value))
@@ -2718,6 +2723,10 @@ const autoHint = computed(() => {
const size = autoResolution(imageWidth.value, imageHeight.value)
return `${size.width} × ${size.height} · from still`
})
const videoAspects = computed(() => aspects.map(option => ({
...option,
hint: option.id === 'auto' ? autoHint.value : option.hint
})))
const videoModelHint = computed(() => {
if (ltxVideo.value) {
return textToVideo.value
@@ -2819,29 +2828,37 @@ const editV2BlockReason = computed(() => {
})
const v2PassChaining = computed(() => studioMode.value === 'editv2' && (v2Mode.value === 'edit' || v2Mode.value === 'compose'))
const v2ModeLocked = computed(() => v2PassChaining.value && editPassQueue.value.length > 0)
const extraPassButtonLabel = computed(() => (
studioMode.value === 'editv2' && v2Mode.value === 'compose' ? '+ Compose pass' : '+ Edit pass'
))
const extraPassButtonLabel = computed(() => {
if (imagePassMode.value === 'batch') return '+ Prompt'
return studioMode.value === 'editv2' && v2Mode.value === 'compose' ? '+ Compose pass' : '+ Edit pass'
})
const extraPassHint = computed(() => {
if (imagePassMode.value === 'batch') {
return 'Every prompt runs from the start still. Outputs do not feed the next prompt.'
}
if (studioMode.value === 'editv2' && v2Mode.value === 'compose') {
return 'Each extra pass is another Compose on the last output, with the same still B. You cannot switch graphs between passes.'
}
return 'Each extra pass is another Edit on the last output. You cannot switch graphs between passes.'
})
const extraPassPlaceholder = computed(() => (
studioMode.value === 'editv2' && v2Mode.value === 'compose'
const extraPassPlaceholder = computed(() => {
if (imagePassMode.value === 'batch') return 'Another prompt for the same start still…'
return studioMode.value === 'editv2' && v2Mode.value === 'compose'
? 'Next compose prompt, applied to the previous pass output…'
: 'Next edit prompt, applied to the previous pass output…'
))
})
const editV2SubmitLabel = computed(() => {
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
const n = v2PassChaining.value ? editPassQueue.value.length : 0
const label = v2Mode.value === 'generate' ? 'Generate image' : v2Mode.value === 'refine' ? 'Refine image' : v2Mode.value === 'compose' ? 'Compose image' : 'Edit image'
if (!occupied) {
if (!n) return label
if (imagePassMode.value === 'batch') return n === 1 ? `${label} × 2` : `${label} × ${n + 1}`
return n === 1 ? `${label} + 1 pass` : `${label} + ${n} passes`
}
if (n) return `Queue ${n + 1} passes`
if (n) {
return imagePassMode.value === 'batch' ? `Queue ${n + 1} from start still` : `Queue ${n + 1} passes`
}
return `Queue ${label.toLowerCase()}`
})
const generateLabel = computed(() => {
@@ -2865,13 +2882,15 @@ const queuedExtensionCount = computed(() => (
const editLabel = computed(() => {
const n = editPassQueue.value.length
if (!n) return 'Edit image'
if (imagePassMode.value === 'batch') return n === 1 ? 'Edit image × 2' : `Edit image × ${n + 1}`
return n === 1 ? 'Edit image + 1 pass' : `Edit image + ${n} passes`
})
const editSubmitLabel = computed(() => {
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
if (!occupied) return editLabel.value
const n = editPassQueue.value.length
return n ? `Queue ${n + 1} passes` : 'Queue edit'
if (!n) return 'Queue edit'
return imagePassMode.value === 'batch' ? `Queue ${n + 1} from start still` : `Queue ${n + 1} passes`
})
const parsedShots = computed(() => parseShotScript(prompt.value))
const recommendedShots = computed(() => {
@@ -3468,12 +3487,12 @@ onMounted(async () => {
autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true'
videoEngine.value = 'minimax'
videoStart.value = 'still'
minimaxGraph.value = 'v1'
minimaxGraph.value = 'v2'
try {
const saved = parseVideoWorkflow(localStorage.getItem('aigen-video-workflow') || '')
videoEngine.value = videoEngineOf(saved)
videoStart.value = videoStartOf(saved)
minimaxGraph.value = minimaxGraphOf(saved)
minimaxGraph.value = minimaxGraphOf(saved) === 'v1' ? 'v2' : minimaxGraphOf(saved)
const engine = localStorage.getItem('aigen-video-engine')
const start = localStorage.getItem('aigen-video-start')
if (engine === 'ltx' || engine === 'minimax') videoEngine.value = engine
@@ -3686,6 +3705,11 @@ function onEditScaleMegapixels() {
editScaleMegapixels.value = clampImageScaleMegapixels(editScaleMegapixels.value)
}
function setV2ScaleOn(on: boolean) {
v2ScaleOn.value = on
if (on) v2Megapixels.value = IMAGE_SCALE_MP_DEFAULT
}
function onCfgSlider(event: Event) {
cfgTouched.value = true
cfg.value = clampCfg(Number((event.target as HTMLInputElement).value))
@@ -3919,7 +3943,8 @@ function currentPresetSnapshot() {
consistencyClip: imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthClip ?? 0,
steps: clampImageSteps(v2Steps.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT),
cfg: clampImageCfg(v2Cfg.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT),
megapixels: v2Mode.value === 'generate' ? undefined : clampImageScaleMegapixels(v2Megapixels.value, 1),
megapixels: v2Mode.value === 'generate' || !v2ScaleOn.value ? undefined : clampImageScaleMegapixels(v2Megapixels.value, 1),
scaleToTotalPixels: v2Mode.value === 'generate' ? undefined : v2ScaleOn.value,
turbo: v2Turbo.value === true,
strength: v2Mode.value === 'generate' || (v2Mode.value !== 'refine' && v2Engine.value !== 'krea') ? undefined : clampImageV2Denoise(v2Denoise.value),
aspect: aspect.value,
@@ -3985,6 +4010,7 @@ function applyGenerationPreset(preset: GenerationPreset) {
v2Steps.value = clampImageSteps(settings.steps, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT)
v2Cfg.value = clampImageCfg(settings.cfg, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT)
v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
v2ScaleOn.value = v2Mode.value !== 'generate' && settings.scaleToTotalPixels !== false
v2Turbo.value = settings.turbo === true
if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate')) v2Denoise.value = clampImageV2Denoise(settings.strength)
aspect.value = parseImageAspect(settings.aspect)
@@ -5277,6 +5303,7 @@ function applyStillSettings(still: LibraryStill) {
if (settings.steps != null) v2Steps.value = clampImageSteps(settings.steps, IMAGE_V2_STEPS_DEFAULT)
if (settings.cfg != null) v2Cfg.value = clampImageCfg(settings.cfg, IMAGE_V2_CFG_DEFAULT)
if (settings.megapixels != null) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
v2ScaleOn.value = mode !== 'generate' && settings.scaleToTotalPixels !== false
v2Turbo.value = settings.turbo === true
if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && settings.strength != null) v2Denoise.value = clampImageV2Denoise(settings.strength)
aspect.value = parseImageAspect(settings.aspect)
@@ -5798,6 +5825,19 @@ function removeEditPass(id: string) {
editPassQueue.value = editPassQueue.value.filter(item => item.id !== id)
}
function imagePassUnit() {
return imagePassMode.value === 'batch' ? 'Batch' : 'Pass'
}
function imagePassPlan(firstPrompt: string, extra: { prompt: string }[], fallback: string) {
const unit = imagePassUnit()
if (!extra.length) return [{ label: fallback, prompt: firstPrompt }]
return [
{ label: `${unit} 1`, prompt: firstPrompt },
...extra.map((item, index) => ({ label: `${unit} ${index + 2}`, prompt: item.prompt }))
]
}
async function editImage() {
if (!file.value || (!prompt.value.trim() && !imageFillWithoutPrompt.value)) {
toast(editBlockReason.value || 'Load a still first.')
@@ -5831,6 +5871,7 @@ async function editImage() {
body.append('name', clipName.value.trim())
if (inputStillId.value) body.append('stillId', inputStillId.value)
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
body.append('passMode', imagePassMode.value)
if (imageLoraStack.value.length) body.append('loraStack', JSON.stringify(imageLoraStack.value))
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
const started = await $fetch<{
@@ -5847,7 +5888,9 @@ async function editImage() {
if (started.queued) {
const n = 1 + extraPasses.length
toast(n > 1
? `Queued ${n} edit passes behind the current job.`
? (imagePassMode.value === 'batch'
? `Queued ${n} edits from the start still behind the current job.`
: `Queued ${n} edit passes behind the current job.`)
: 'Queued behind the current job.')
await refreshStudioQueue()
return
@@ -5865,13 +5908,10 @@ async function editImage() {
editMaxStep.value = started.steps
editChainStep.value = 1
editChainTotal.value = 1 + extraPasses.length
editChainLabel.value = extraPasses.length ? 'Pass 1' : ''
editChainLabel.value = extraPasses.length ? `${imagePassUnit()} 1` : ''
editOverallProgress.value = 0
editCompletedChainStep.value = 0
editActiveChainPlan.value = [
{ label: 'Pass 1', prompt: prompt.value.trim() },
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
]
editActiveChainPlan.value = imagePassPlan(prompt.value.trim(), extraPasses, 'Pass 1')
startTimer('edit')
editDownloadName.value = downloadName
editJobId.value = started.jobId
@@ -5936,9 +5976,9 @@ function setV2Engine(engine: ImageV2Engine) {
}
}
function setImageAspect(id: AspectId) {
aspect.value = id
const size = generateSizeForAspect(id)
function setImageAspect(id: string) {
aspect.value = normalizeAspect(id)
const size = generateSizeForAspect(aspect.value)
v2Width.value = size.width
v2Height.value = size.height
}
@@ -6060,7 +6100,10 @@ async function editImageV2() {
body.append('consistency_clip', String(imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthClip ?? 0))
body.append('steps', String(clampImageSteps(v2Steps.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT)))
body.append('cfg', String(clampImageCfg(v2Cfg.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT)))
if (v2Mode.value !== 'generate') body.append('megapixels', String(clampImageScaleMegapixels(v2Megapixels.value, 1)))
if (v2Mode.value !== 'generate') {
body.append('scaleToTotalPixels', String(v2ScaleOn.value))
if (v2ScaleOn.value) 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)
@@ -6069,6 +6112,7 @@ async function editImageV2() {
body.append('name', clipName.value.trim())
if (inputStillId.value) body.append('stillId', inputStillId.value)
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
body.append('passMode', imagePassMode.value)
const v2Stack = filterLoraStackForImageEngine(
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
v2Engine.value
@@ -6096,7 +6140,9 @@ async function editImageV2() {
if (started.queued) {
const n = 1 + extraPasses.length
toast(n > 1
? `Queued ${n} Image v2 passes behind the current job.`
? (imagePassMode.value === 'batch'
? `Queued ${n} Image v2 stills from the start image behind the current job.`
: `Queued ${n} Image v2 passes behind the current job.`)
: (v2Engine.value === 'krea' ? 'Queued Krea generate behind the current job.' : 'Queued Image v2 behind the current job.'))
await refreshStudioQueue()
return
@@ -6114,16 +6160,11 @@ async function editImageV2() {
editMaxStep.value = started.steps
editChainStep.value = 1
editChainTotal.value = 1 + extraPasses.length
editChainLabel.value = extraPasses.length ? 'Pass 1' : ''
editChainLabel.value = extraPasses.length ? `${imagePassUnit()} 1` : ''
editOverallProgress.value = 0
editCompletedChainStep.value = 0
const passNoun = v2Mode.value === 'compose' ? 'Compose' : v2Mode.value === 'refine' ? 'Refine' : v2Mode.value === 'generate' ? 'Generate' : 'Edit'
editActiveChainPlan.value = extraPasses.length
? [
{ label: 'Pass 1', prompt: prompt.value.trim() },
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
]
: [{ label: passNoun, prompt: prompt.value.trim() }]
editActiveChainPlan.value = imagePassPlan(prompt.value.trim(), extraPasses, passNoun)
startTimer('edit')
editDownloadName.value = downloadName
editJobId.value = started.jobId
@@ -6181,7 +6222,7 @@ function applyVideoWorkflow(raw?: string) {
const parsed = coerceVideoWorkflow(raw, ltxEnabled.value)
videoEngine.value = videoEngineOf(parsed)
videoStart.value = videoStartOf(parsed)
minimaxGraph.value = minimaxGraphOf(parsed)
minimaxGraph.value = minimaxGraphOf(parsed) === 'v1' ? 'v2' : minimaxGraphOf(parsed)
}
async function ensureVideoInputStill() {