Files
aigen/components/studio2/Bench.vue
T
TowstyandCursor 30be4bd5b0 Wire Qwen Edit Start still to image_1 and PE-I2I identity keep.
Edit no longer invents strangers when the prompt omits <image1> or PE drops image tokens; Hero stays Klein-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 14:27:36 -05:00

916 lines
45 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="bench-root" :data-display="displayMode" :data-mobile="isMobile ? '1' : undefined">
<Studio2Mobile v-if="isMobile" :ctx="mobileCtx" />
<template v-else>
<header class="bench-header">
<div class="bench-brand">{{ instanceName }}</div>
<div class="bench-crumb" aria-label="Folder">
<span aria-hidden="true">📁</span>
<span>/ Projects /</span>
<select v-model="form.folderId" :aria-label="'Folder'">
<option v-for="f in library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
</div>
<div class="bench-header-right">
<nav class="bench-nav" aria-label="Studio links">
<NuxtLink to="/library">Library</NuxtLink>
<NuxtLink to="/queue">Queue</NuxtLink>
<NuxtLink to="/settings">Settings</NuxtLink>
</nav>
<span class="gpu-chip" :data-tone="gpu.tone" role="status" :title="gpu.detail">
<span class="dot" aria-hidden="true" />
{{ gpuChipLabel }}
</span>
</div>
</header>
<nav class="rail rail-task" aria-label="Tasks">
<button
v-for="task in tasks"
:key="task"
type="button"
class="seg"
:aria-pressed="form.mode===task"
@click="chooseTask(task)"
>{{ task }}</button>
</nav>
<div v-if="form.mode!=='music'" class="rail rail-model" role="group" aria-label="Model">
<button
v-for="opt in engineOptions"
:key="opt.value"
type="button"
class="seg"
:aria-pressed="form.engine===opt.value"
:disabled="opt.disabled"
:title="opt.hint || undefined"
@click="!opt.disabled && (form.engine=opt.value)"
>{{ opt.label }}</button>
</div>
<p v-else class="rail rail-model text-[var(--muted)]">YuE2</p>
<div class="bench-grid">
<!-- LEFT inspector -->
<aside class="bench-panel">
<div class="bench-scroll inspector">
<div class="classic-task-model space-y-2">
<label>
<span class="field-label">Task</span>
<select :value="form.mode" class="field-control capitalize" @change="onTaskSelect">
<option v-for="task in tasks" :key="task" :value="task" class="capitalize">{{ task }}</option>
</select>
</label>
<label v-if="form.mode!=='music'">
<span class="field-label">Model</span>
<select v-model="form.engine" class="field-control">
<option v-for="opt in engineOptions" :key="opt.value" :value="opt.value" :disabled="opt.disabled">{{ opt.label }}{{ opt.hint ? ' — '+opt.hint : '' }}</option>
</select>
</label>
<p v-else class="text-[var(--type-sm)] text-[var(--muted)]">Model · YuE2</p>
</div>
<template v-if="form.mode==='music'">
<label><span class="field-label">Genre / style tags</span><textarea v-model="music.tags" rows="3" class="field-area" :placeholder="tagsPlaceholder" /></label>
<label><span class="field-label">Lyrics</span><textarea v-model="music.lyrics" rows="8" class="field-area" :placeholder="lyricsPlaceholder" /></label>
<label><span class="field-label">Target length (sec)</span><input v-model.number="music.duration" type="number" min="15" max="150" class="field-control"></label>
<label><span class="field-label">Seed</span><input v-model="music.seed" placeholder="random" class="field-control"></label>
<label><span class="field-label">Track name</span><input v-model="music.name" class="field-control" placeholder="Optional"></label>
</template>
<template v-else>
<Studio2AssetSlot
size="hero"
:label="heroSlotLabel"
:asset="stills.find((a:any)=>a.id===form.identityStillId)"
:hidden="slotHidden(form.identityStillId)"
upload
:disabled="uploading"
@pick="picker='identityStillId'"
@upload="upload($event,'hero')"
/>
<Studio2AssetSlot
:label="sourceSlotLabel"
:asset="sourceSlot"
:hidden="slotHidden(form.mode==='extend'?form.startClipId:form.imageAId)"
upload
:disabled="uploading"
:class="{ 'slot-required': editSourceRequired && !form.imageAId }"
@pick="picker=form.mode==='extend'?'startClipId':'imageAId'"
@upload="upload($event,'source')"
/>
<p v-if="editSourceRequired && !form.imageAId" class="text-[11px] text-[var(--amber)]">Required for Edit</p>
<p v-if="slotPairHint" class="text-[11px] text-[var(--muted)]">{{ slotPairHint }}</p>
<Studio2AssetSlot v-if="form.mode==='compose'" label="Second still" :asset="stills.find((a:any)=>a.id===form.imageBId)" :hidden="slotHidden(form.imageBId)" @pick="picker='imageBId'" />
<Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="slotHidden(form.maskId)" @pick="picker='maskId'" />
<Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="slotHidden(form.endStillId)" @pick="picker='endStillId'" />
<div v-if="form.engine!=='qwen21'" class="identity-slots flex flex-col gap-2 text-[var(--type-sm)] text-[var(--muted)]" aria-label="Identity locks">
<label class="inline-flex items-center gap-2"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
<label class="inline-flex items-center gap-2"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
<p class="text-[11px]">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
</div>
<p v-else-if="form.mode==='edit'" class="text-[var(--type-sm)] text-[var(--muted)]">Qwen Edit uses Start still as &lt;image1&gt;. Hero is Klein-only and is ignored.</p>
<p v-else class="text-[var(--type-sm)] text-[var(--muted)]">Qwen 2.1 — no hero, locks, or LoRA stack.</p>
<label>
<span class="field-label">Prompt</span>
<textarea v-model="form.promptSections.action" class="field-area" placeholder="Describe the image or the shot." />
</label>
<label v-if="form.engine==='qwen21' && (form.mode==='generate' || form.mode==='edit')" class="inline-flex items-center gap-2 text-[var(--type-sm)] text-[var(--muted)]">
<input v-model="form.enhancePrompt" type="checkbox" class="accent-[var(--amber)]">Enhance prompt
</label>
<div v-if="form.engine==='qwen21' && (form.mode==='generate' || form.mode==='edit')" class="flex flex-col gap-1">
<label class="inline-flex items-center gap-2 text-[var(--type-sm)] text-[var(--muted)]">
<input v-model="form.turbo" type="checkbox" class="accent-[var(--amber)]">Turbo
</label>
<p v-if="form.turbo" class="text-[11px] text-[var(--muted)]">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
</div>
<div>
<span class="field-label">Negative prompt</span>
<div class="neg-row">
<span v-for="chip in negativeChips" :key="chip" class="neg-chip">
{{ chip }}
<button type="button" :aria-label="'Remove '+chip" @click="removeNegative(chip)">×</button>
</span>
</div>
<div class="neg-add-row">
<input
v-model="negativeDraft"
type="text"
class="field-control"
placeholder="Add negative chip"
@keydown.enter.prevent="addNegative"
>
<button type="button" class="text-[var(--type-sm)] text-[var(--amber)]" @click="addNegative">Add</button>
</div>
</div>
<div v-if="form.engine!=='qwen21'">
<span class="field-label">LoRA</span>
<LoraSelect v-model="form.settings.loraStack" compact :names="pickerLoras" :use="currentLoraUse" :clip-strength="!video" filename-subtitle :aliases="{...loraNames.aliases,...library.loraAliases}" :triggers="{...loraNames.triggers,...library.loraTriggers}" missing-label="None" @insert-trigger="form.promptSections.action += ' '+$event" />
</div>
<div>
<span class="field-label">Seed</span>
<div class="seed-row">
<input
:value="form.settings.seed ?? ''"
type="number"
min="0"
max="2147483647"
class="field-control"
@input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'"
>
<button type="button" class="dice-btn" title="Random seed" aria-label="Random seed" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">🎲</button>
</div>
</div>
<label>
<span class="field-label">Aspect ratio</span>
<select v-model="form.settings.aspect" class="field-control">
<option v-for="opt in aspectOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
</select>
</label>
<button type="button" class="text-left text-[var(--type-sm)] text-[var(--amber)]" @click="settingsOpen=true">More settings (steps / CFG)…</button>
<p v-if="uploading || uploadMessage" role="status" class="text-[var(--type-sm)] text-[var(--amber)]">{{ uploading?'Uploading…':uploadMessage }}</p>
<p v-if="error" role="alert" class="text-[var(--type-sm)] text-red-300">{{ error }}</p>
</template>
</div>
</aside>
<!-- CENTER viewer -->
<section class="bench-panel">
<div v-if="form.mode!=='music'" class="viewer-lock-bar" aria-label="Locks and hide">
<template v-if="form.engine!=='qwen21'">
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
</template>
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideInput" type="checkbox" class="accent-[var(--amber)]">Hide input</label>
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideOutput" type="checkbox" class="accent-[var(--amber)]">Hide output</label>
</div>
<div class="center-stack">
<div class="viewer-frame">
<button
v-if="selected && xaigen"
type="button"
class="eye-viewer"
:aria-pressed="!isHidden(selected)"
:aria-label="isHidden(selected) ? 'Show preview' : 'Hide preview'"
@click.stop="toggleReveal(selected.id)"
>
<img :src="isHidden(selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
</button>
<template v-if="selected && isHidden(selected)">
<p class="placeholder-still">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : selected.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</p>
</template>
<template v-else-if="selected?.kind==='track'">
<div class="w-full max-w-xl px-6 py-4">
<AudioPlayer :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" />
</div>
</template>
<template v-else-if="selected?.kind==='clip'">
<video
ref="viewerVideo"
:key="selected.id"
:src="`/api/library/clips/${selected.id}/video`"
:muted="viewerMuted"
@timeupdate="onVideoTime"
@loadedmetadata="onVideoTime"
/>
</template>
<template v-else-if="selected">
<img :src="stillUrl(selected.id)" alt="Selected still">
</template>
<template v-else>
<p class="placeholder-still">Select a still or clip in the strip</p>
</template>
</div>
<div class="filmstrip" ref="filmstripEl" aria-label="Current family outputs">
<div
v-for="o in family"
:key="o.id"
class="tile"
:data-active="selected?.id===o.id ? 'true' : undefined"
:ref="el => setTileRef(o.id, el)"
>
<button type="button" class="pick" @click="selectOutput(o)">
<span v-if="isHidden(o)" class="flex h-full items-center justify-center p-1 text-[10px] text-[var(--muted)]">Hidden</span>
<span v-else-if="o.kind==='track'" class="flex h-full items-center justify-center text-[10px] text-[var(--amber)]">Audio</span>
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : stillUrl(o.id)" alt="">
</button>
<button
v-if="xaigen"
type="button"
class="eye-tile"
:aria-label="isHidden(o) ? 'Show tile' : 'Hide tile'"
@click.stop="toggleReveal(o.id)"
>
<img :src="isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
</button>
<button
v-if="selected?.id===o.id"
type="button"
class="tile-trash"
aria-label="Delete selected"
title="Delete"
@click.stop="deleteOutput"
>🗑</button>
</div>
</div>
<div v-if="selected && selected.kind!=='track'" class="strip-actions" aria-label="Selected still actions">
<button type="button" @click="applyUseAsSource">Use as start / input</button>
<button type="button" @click="applyUseAsHero">Use as hero</button>
<button type="button" @click="copySelectedPrompt">Copy prompt</button>
<button type="button" @click="loadSelectedJob">Load</button>
<button type="button" @click="deleteOutput">Delete</button>
</div>
<div class="transport">
<span>{{ transportLabel }}</span>
<div class="ops">
<button type="button" :aria-pressed="viewerMuted" :aria-label="viewerMuted ? 'Unmute' : 'Mute'" @click="viewerMuted=!viewerMuted">{{ viewerMuted ? '🔇' : '🔊' }}</button>
<button type="button" aria-label="Fullscreen" @click="toggleFullscreen">⛶</button>
</div>
</div>
</div>
</section>
<!-- RIGHT queue -->
<aside class="bench-panel queue-col">
<div class="queue-head">
Queue
<span class="queue-badge">{{ queueCount }}</span>
</div>
<div class="queue-list">
<div
v-for="card in queueCards"
:key="card.id"
class="job-card"
:data-active="focusedJobId===card.id ? 'true' : undefined"
@click="focusQueueCard(card)"
>
<div class="job-id">{{ card.code }}</div>
<div class="job-title">{{ card.title }}</div>
<div class="job-meta">{{ card.meta }}</div>
<div class="job-status" :data-state="card.state">
<span class="left"><span class="dot" aria-hidden="true" />{{ card.statusLabel }}</span>
<button v-if="card.cancelable" type="button" class="kebab" aria-label="Cancel job" @click.stop="cancel(card.job)">⋮</button>
</div>
</div>
<p v-if="!queueCards.length" class="px-1 text-[var(--type-sm)] text-[var(--muted)]">No active jobs</p>
</div>
<div class="queue-foot">
<button type="button" class="generate-cta" :disabled="submitting" @click="queue">
<span aria-hidden="true">▶</span>
{{ submitting ? 'Queuing…' : 'Generate' }}
</button>
<p class="tiny">
Job queue
<button v-if="canClearFinished" type="button" @click="clearFinished"> · Clear finished</button>
</p>
</div>
</aside>
</div>
</template>
<div v-if="settingsOpen || detailsOpen" class="fixed inset-0 z-50 bg-black/70" @click.self="settingsOpen=false;detailsOpen=false">
<aside class="absolute inset-y-0 right-0 w-full max-w-md overflow-auto border-l border-[var(--line)] bg-[var(--bg)] p-6">
<button type="button" class="float-right" @click="settingsOpen=false;detailsOpen=false">Close</button>
<template v-if="settingsOpen">
<h2 class="mb-6 text-xl font-semibold">More settings</h2>
<p class="mb-3 text-[var(--type-sm)] text-[var(--muted)]">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding</p>
<label v-for="key in ['steps','cfg',...(video?['duration','fps']:[])]" :key="key" class="mb-4 block capitalize">{{ key }}
<input v-model.number="form.settings[key]" type="number" class="field-control mt-1">
</label>
<label v-if="video" class="mb-4 flex items-center gap-2"><input v-model="form.settings.turbo" type="checkbox" class="accent-[var(--amber)]"> Turbo</label>
<details v-if="video || form.mode==='edit'" class="mt-4">
<summary>{{ video?'Queue more shots':'Queue extra edit passes' }}</summary>
<textarea v-model="batchText" rows="5" class="field-area mt-2" placeholder="First additional prompt&#10;&#10;Next additional prompt" />
</details>
<button type="button" class="mt-4 text-[var(--type-sm)] text-[var(--amber)]" @click="loadLoras(true)">Refresh LoRAs</button>
</template>
<template v-else>
<h2 class="mb-6 text-xl font-semibold">Output details</h2>
<Studio2Details :metadata="selected?.studio2" />
</template>
</aside>
</div>
<div v-if="picker" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6" @click.self="closePicker">
<section role="dialog" aria-modal="true" aria-label="Choose source asset" class="max-h-[80vh] w-full max-w-4xl overflow-auto rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel)] p-5">
<div class="mb-4 flex justify-between"><h2>Choose {{ picker==='identityStillId'?'hero':'source' }}</h2><button type="button" @click="closePicker">Close</button></div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<button type="button" class="text-[var(--type-sm)] text-[var(--muted)]" @click="pickAsset(null)">Clear slot</button>
<button
v-if="xaigen && pickerAssets.length"
type="button"
class="text-[var(--type-sm)]"
:class="pickerRevealAll ? 'text-[var(--amber)]' : 'text-[var(--muted)]'"
:title="pickerRevealAll ? 'Hide all thumbnails in this picker' : 'Reveal all thumbnails in this picker'"
@click="pickerRevealAll=!pickerRevealAll"
>{{ pickerRevealAll ? 'Hide all' : 'Reveal all' }}</button>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
<button v-for="a in pickerAssets" :key="a.id" type="button" class="rounded-[var(--radius)] border border-[var(--line)] p-2 text-left" @click="pickAsset(a)">
<span v-if="pickerThumbHidden(a)" class="flex h-24 items-center text-xs text-[var(--muted)]">Still loaded · preview hidden</span>
<img v-else :src="a.kind==='clip'?'/api/library/clips/'+a.id+'/thumb':stillUrl(a.id)" class="h-24 w-full object-contain" alt="">
<span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span>
</button>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
import {sectionsFrom} from '~/shared/studio2/contracts.mjs'
import {aspectOptions,resolveSize,bindClip,restoreAll,useAsHero,useAsSource,hydrateStudio2Job} from '~/shared/studio2/input.mjs'
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
import {queueJobs} from '~/shared/studio2/queue-view.mjs'
import {gpuStatus} from '~/shared/studio2/status.mjs'
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs'
import {randomSeed} from '~/shared/studio2/seed.mjs'
import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
import {isXaigenStudio} from '~/utils/videoModels'
import {LOAD_GENERATION_PAYLOAD_KEY} from '~/utils/queuedJob'
const NEG_STORAGE_KEY='studio2-negative-chips'
const ENHANCE_STORAGE_KEY='studio2-qwen21-enhance-prompt'
const TURBO_STORAGE_KEY='studio2-qwen21-turbo'
function readStoredNegatives(){
if(!import.meta.client)return [...defaultNegatives]
try{
const raw=localStorage.getItem(NEG_STORAGE_KEY)
if(raw===null)return [...defaultNegatives]
const parsed=JSON.parse(raw)
return Array.isArray(parsed)?parsed.map((s:any)=>String(s||'').trim()).filter(Boolean):[...defaultNegatives]
}catch{return [...defaultNegatives]}
}
function writeStoredNegatives(list:string[]){
if(!import.meta.client)return
try{localStorage.setItem(NEG_STORAGE_KEY,JSON.stringify(list))}catch{/* ignore */}
}
function readStoredEnhancePrompt(){
if(!import.meta.client)return false
try{return localStorage.getItem(ENHANCE_STORAGE_KEY)==='1'}catch{return false}
}
function writeStoredEnhancePrompt(on:boolean){
if(!import.meta.client)return
try{localStorage.setItem(ENHANCE_STORAGE_KEY,on?'1':'0')}catch{/* ignore */}
}
function readStoredTurbo(){
if(!import.meta.client)return false
try{return localStorage.getItem(TURBO_STORAGE_KEY)==='1'}catch{return false}
}
function writeStoredTurbo(on:boolean){
if(!import.meta.client)return
try{localStorage.setItem(TURBO_STORAGE_KEY,on?'1':'0')}catch{/* ignore */}
}
const xaigen=isXaigenStudio(useRuntimeConfig().public)
const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen')
const {displayMode,isClassic}=useDisplayMode()
const {isMobile}=useIsMobile()
const classicHideInput=ref(true)
const classicHideOutput=ref(true)
const picker=ref('')
const pickerRevealAll=ref(false)
const uploading=ref(false),uploadMessage=ref('')
const revealed=ref<Set<string>>(new Set())
const measured=reactive<Record<string,{width:number;height:number}>>({}),seenOutputs=new Set<string>()
const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),healthState=ref<any>(null),upscales=ref<any[]>([])
const library=ref<any>({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
const studioQueue=ref<any[]>([])
const liveMusic=ref<Record<string,any>>({})
const negativeDraft=ref('')
const filmstripEl=ref<HTMLElement|null>(null)
const tileRefs=new Map<string,HTMLElement>()
const form=reactive<any>({mode:'generate',engine:'flux',enhancePrompt:readStoredEnhancePrompt(),turbo:readStoredTurbo(),imageStyles:{positive:[],negative:readStoredNegatives()},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''})
const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
let loraMemory=createLoraMemory(xaigen)
const currentLoraUse=computed(()=>loraUse(form))
const pickerLoras=computed(()=>!xaigen && currentLoraUse.value==='ltx'?[]:filterLorasForUse(filterLorasForStudio([...loraNames.value.image,...loraNames.value.video],xaigen),currentLoraUse.value))
watch(()=>[currentLoraUse.value,form.settings.loraStack],()=>loraMemory.sync(form),{deep:true,flush:'sync'})
const route=useRoute()
const tasks=['generate','edit','compose','refine','video','extend','music']
const settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('')
const viewerVideo=ref<HTMLVideoElement|null>(null)
const viewerMuted=ref(false)
const videoTime=ref(0)
const videoDuration=ref(0)
const focusedJobId=ref('')
const video=computed(()=>['video','extend'].includes(form.mode))
const kreaSticky=computed(()=>['edit','compose','iterate'].includes(form.mode))
const qwenAllowed=computed(()=>['generate','edit'].includes(form.mode))
const engineOptions=computed(()=>{
if(video.value){
return [
{value:'minimax',label:'MiniMax H3',disabled:false,hint:''},
{value:'ltx',label:'LTX',disabled:!xaigen,hint:xaigen?'':'xAIGen only'}
]
}
return [
{value:'flux',label:'Klein',disabled:false,hint:''},
{value:'krea',label:'Krea 2',disabled:kreaSticky.value,hint:kreaSticky.value?'sticky hero = Klein':''},
{value:'qwen21',label:'Qwen 2.1',disabled:!qwenAllowed.value,hint:qwenAllowed.value?'':'Generate and Edit only'}
]
})
watch(engineOptions,opts=>{
const current=opts.find(o=>o.value===form.engine)
if(!current || current.disabled)form.engine=opts.find(o=>!o.disabled)?.value || form.engine
})
watch(()=>form.engine,(engine,prev)=>{
if(engine==='qwen21' && prev!=='qwen21'){
form.settings.steps=form.turbo?6:25
form.settings.cfg=1
form.lockFace=false
form.lockOutfit=false
form.settings.loraStack=[]
}
})
watch(()=>form.enhancePrompt,on=>writeStoredEnhancePrompt(!!on))
watch(()=>form.turbo,on=>{
writeStoredTurbo(!!on)
if(form.engine!=='qwen21')return
form.settings.steps=on?6:25
form.settings.cfg=1
})
watch(()=>[form.mode,form.engine],([mode,engine])=>{
if(engine==='qwen21' && mode==='edit'){
form.settings.steps=form.turbo?6:25
form.settings.cfg=1
form.lockFace=false
form.lockOutfit=false
form.settings.loraStack=[]
}
})
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
const stillUrl=(id:string)=>`/api/library/stills/${id}`
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
const benchJobs=computed(()=>queueJobs(folderJobs.value))
const musicJobs=computed(()=>studioQueue.value
.filter((j:any)=>j.kind==='music' && j.folderId===form.folderId && !['complete','done'].includes(j.status))
.map((j:any)=>({...j,live:j.liveJobId?liveMusic.value[j.liveJobId]:null})))
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value))
const gpuChipLabel=computed(()=>{
const raw=String(healthState.value?.comfy?.gpu || healthState.value?.gpuName || healthState.value?.comfy?.device || '').trim()
const short=raw.match(/(\d{3,4})\b/)?.[1]
if(gpu.value.tone==='ready') return short ? `${short} Ready` : 'Ready'
return gpu.value.label
})
const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value})
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId))
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId)))
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine))
const negativeChips=computed(()=>form.imageStyles.negative || [])
const editSourceRequired=computed(()=>['edit','compose','refine'].includes(form.mode))
const heroSlotLabel=computed(()=>editSourceRequired.value?'Identity / reference':'Hero still')
const sourceSlotLabel=computed(()=>{
if(form.mode==='extend')return 'Previous clip'
if(editSourceRequired.value)return form.engine==='qwen21'?'Start still':'Edit source'
return 'Start still'
})
const slotPairHint=computed(()=>{
if(form.mode==='refine')return form.engine==='krea'
? 'Krea Refine: Edit source is the picture; Mask is the refine region.'
: 'Refine: Edit source is the picture; Mask marks the region to change.'
if(!editSourceRequired.value)return ''
if(form.engine==='qwen21')return 'Qwen Edit uses Start still as <image1>. Hero is Klein-only and is ignored.'
return 'Klein: Hero = identity encode. Edit source = the picture you are changing.'
})
const queueCards=computed(()=>{
const cards:any[]=[]
for(const job of benchJobs.value){
const action=job.request?.promptSections?.action || 'Job'
const eng=job.request?.engine==='ltx'?'LTX':job.request?.engine==='krea'?'Krea 2':job.request?.engine==='qwen21'?'Qwen 2.1':job.request?.engine==='minimax'?'MiniMax H3':'Klein'
const mode=job.request?.mode || 'generate'
const modeLabel=mode.charAt(0).toUpperCase()+mode.slice(1)
const aspect=job.request?.settings?.aspect || 'auto'
const state=job.state==='waiting'?'queued':job.state
cards.push({
id:job.id,
job,
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
title:action.slice(0,72),
meta:`${modeLabel} · ${eng} · ${aspect}`,
state,
statusLabel:state==='queued'?'Queued':state==='rendering'||state==='submitting'||state==='waking'||state==='saving'?'Running':state==='complete'?'Ready':state,
cancelable:!['complete','failed','cancelled'].includes(job.state)
})
}
for(const job of musicJobs.value){
cards.push({
id:job.id,
job,
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
title:job.name || 'YuE2',
meta:`Music · YuE2 · ${job.duration || '—'}s`,
state:job.live?.status || job.status,
statusLabel:job.live?.status || job.status,
cancelable:!['complete','done','error','cancelled'].includes(job.status)
})
}
for(const job of upscales.value.filter((j:any)=>j.folderId===form.folderId && ['queued','running'].includes(j.status))){
cards.push({
id:job.id,
job,
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
title:job.name || 'Video upscale',
meta:'Upscale',
state:job.status,
statusLabel:job.status==='running'?'Running':'Queued',
cancelable:false
})
}
return cards
})
const queueCount=computed(()=>queueCards.value.length)
const canClearFinished=computed(()=>folderJobs.value.some(j=>['complete','failed','cancelled'].includes(j.state)) || musicJobs.value.some(j=>['complete','done','error','cancelled'].includes(j.status)))
const transportLabel=computed(()=>{
if(selected.value?.kind==='clip') return `${formatTime(videoTime.value)} / ${formatTime(videoDuration.value)}`
if(selected.value?.kind==='track') return 'Audio'
if(selected.value) return 'Still'
return '—'
})
function formatTime(sec:number){
const s=Math.max(0,Number(sec)||0)
const m=Math.floor(s/60)
const r=s-m*60
const whole=Math.floor(r)
const frac=Math.floor((r-whole)*100)
return `${String(m).padStart(2,'0')}:${String(whole).padStart(2,'0')}.${String(frac).padStart(2,'0')}`
}
function onVideoTime(){
const el=viewerVideo.value
if(!el)return
videoTime.value=el.currentTime||0
videoDuration.value=el.duration||0
}
function toggleFullscreen(){
const el=viewerVideo.value || document.querySelector('.viewer-frame')
if(el && 'requestFullscreen' in el) void (el as HTMLElement).requestFullscreen()
}
function removeNegative(chip:string){
form.imageStyles={...form.imageStyles,negative:(form.imageStyles.negative||[]).filter((c:string)=>c!==chip)}
writeStoredNegatives(form.imageStyles.negative)
}
function addNegative(){
const chip=negativeDraft.value.trim()
if(!chip)return
const list=form.imageStyles.negative||[]
if(!list.includes(chip)){
form.imageStyles={...form.imageStyles,negative:[...list,chip]}
writeStoredNegatives(form.imageStyles.negative)
}
negativeDraft.value=''
}
function setTileRef(id:string,el:any){
if(el)tileRefs.set(id,el as HTMLElement)
else tileRefs.delete(id)
}
function scrollFilmstripToNewest(){
nextTick(()=>{
const id=selected.value?.id || family.value.at(-1)?.id
const tile=id?tileRefs.get(id):null
const strip=filmstripEl.value
if(tile?.scrollIntoView)tile.scrollIntoView({inline:'end',block:'nearest',behavior:'smooth'})
else if(strip)strip.scrollLeft=strip.scrollWidth
})
}
function applyUseAsSource(){
if(!selected.value||selected.value.kind==='track')return
Object.assign(form,useAsSource(form,selected.value))
uploadMessage.value=`Using ${assetLabel(selected.value)} as start / input.`
}
function applyUseAsHero(){
if(!selected.value||selected.value.kind==='track')return
Object.assign(form,useAsHero(form,selected.value))
uploadMessage.value=`Using ${assetLabel(selected.value)} as hero.`
}
async function copySelectedPrompt(){
const text=selected.value?.studio2?.promptSections?.action
|| selected.value?.studio2?.compiledPrompt
|| selected.value?.prompt
|| ''
try{
if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||''))
uploadMessage.value=text?'Prompt copied.':'No prompt on this item.'
}catch{error.value='Could not copy prompt.'}
}
function stillIdSet(){return new Set(stills.value.map((s:any)=>s.id))}
function clipIdSet(){return new Set(clips.value.map((c:any)=>c.id))}
function applyHydratedJob(saved:any,output?:any){
const {form:next,missing}=hydrateStudio2Job(form,saved,{stillIds:stillIdSet(),clipIds:clipIdSet(),xaigen,memory:loraMemory})
Object.assign(form,next)
form.guides=form.guides||[]
batchText.value=''
if(output)selected.value=output
else if(form.mode==='music'){/* keep */}
error.value=missing||''
if(missing)uploadMessage.value=missing
else uploadMessage.value='Job loaded into the bench.'
}
function loadSelectedJob(){
if(!selected.value)return
const saved=selected.value.studio2
if(!saved){error.value='No saved job on this still.';return}
applyHydratedJob(saved,selected.value)
}
async function applyIncomingGenerationLoad(){
if(!import.meta.client)return
let raw=''
try{raw=sessionStorage.getItem(LOAD_GENERATION_PAYLOAD_KEY)||''}catch{return}
if(!raw)return
try{sessionStorage.removeItem(LOAD_GENERATION_PAYLOAD_KEY)}catch{/* ignore */}
let loaded:any
try{loaded=JSON.parse(raw)}catch{error.value='Could not read that saved generation.';return}
const payload=loaded?.payload||{}
if(loaded?.kind==='music' || payload.lyrics != null || payload.tags != null){
form.mode='music'
if(payload.folderId)form.folderId=payload.folderId
music.tags=String(payload.tags||payload.prompt||music.tags)
music.lyrics=String(payload.lyrics||music.lyrics)
if(payload.duration!=null)music.duration=Number(payload.duration)||music.duration
if(payload.seed!=null)music.seed=String(payload.seed)
if(payload.name)music.name=String(payload.name)
uploadMessage.value='Music job loaded into the bench.'
return
}
let request=payload.studio2Request
if(!request && payload.studio2Id){
await refresh()
request=jobs.value.find((j:any)=>j.id===payload.studio2Id)?.request
}
if(!request){error.value='That generation has no Studio 2 request to load.';return}
if(request.folderId)form.folderId=request.folderId
await refresh()
const outputs=jobs.value.find((j:any)=>j.id===payload.studio2Id)?.outputs||[]
const outId=outputs.at(-1)?.id
const output=outId?family.value.find((a:any)=>a.id===outId):null
applyHydratedJob(request,output||undefined)
scrollFilmstripToNewest()
}
function focusQueueCard(card:any){
focusedJobId.value=card.id
if(card.job?.outputs?.length)focusJob(card.job)
}
function isHidden(asset:any){
if(!xaigen || !asset?.id)return false
if(isClassic.value){
const input=asset.role==='input'
return input?classicHideInput.value:classicHideOutput.value
}
return previewHidden(asset,revealed.value,xaigen)
}
function pickerThumbHidden(asset:any){
if(pickerRevealAll.value)return false
return isHidden(asset)
}
function slotHidden(id:string){
if(!xaigen || !id)return false
if(isClassic.value)return classicHideInput.value
return !revealed.value.has(id)
}
function toggleReveal(id:string){
if(!id)return
const next=new Set(revealed.value)
if(next.has(id))next.delete(id);else next.add(id)
revealed.value=next
if(import.meta.client)localStorage.setItem(`studio2-revealed:${form.folderId}`,JSON.stringify([...next]))
}
function closePicker(){
picker.value=''
pickerRevealAll.value=false
}
function loadRevealed(folderId:string){
if(!import.meta.client||!xaigen){revealed.value=new Set();return}
try{revealed.value=new Set(JSON.parse(localStorage.getItem(`studio2-revealed:${folderId}`)||'[]'))}
catch{revealed.value=new Set()}
}
watch(()=>sourceAsset.value?.id,id=>{const asset=sourceAsset.value;if(!import.meta.client||!asset||measured[id])return;const media=asset.id===form.startClipId?document.createElement('video'):new Image();if(media.tagName==='VIDEO')(media as HTMLVideoElement).preload='metadata';const done=()=>{measured[asset.id]={width:(media as HTMLVideoElement).videoWidth||(media as HTMLImageElement).naturalWidth,height:(media as HTMLVideoElement).videoHeight||(media as HTMLImageElement).naturalHeight}};media.addEventListener(media.tagName==='VIDEO'?'loadedmetadata':'load',done,{once:true});media.src=media.tagName==='VIDEO'?`/api/library/clips/${asset.id}/video`:stillUrl(asset.id)})
watch(()=>selected.value?.id,()=>{videoTime.value=0;videoDuration.value=0})
function chooseTask(task:string){
if(task==='music'){form.mode='music';return}
if(task==='extend'){
form.mode='extend'
form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'
const clip=selected.value?.kind==='clip'?selected.value:clips.value.find((c:any)=>c.id===form.startClipId)
if(clip?.id){
try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}
catch(e:any){error.value=e?.message||'Could not copy clip settings. Previous clip is still bound.';form.startClipId=clip.id}
}
return
}
const was=video.value;form.mode=task;form.startClipId='';if(video.value!==was){form.engine=video.value?(xaigen && form.engine==='ltx'?'ltx':'minimax'):'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4}
if(selected.value?.kind==='track' && task!=='music') selected.value=family.value.find((a:any)=>a.kind!=='track') || null
}
function onTaskSelect(event:Event){chooseTask((event.target as HTMLSelectElement).value)}
function chooseClip(id:string){if(!id){form.startClipId='';return}const clip=clips.value.find((c:any)=>c.id===id);if(clip){try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}catch(e:any){error.value=e?.message||'Could not copy clip settings.';form.startClipId=clip.id;form.mode='extend';form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'}}}
function selectOutput(item:any){selected.value=item;if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)}
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null);focusedJobId.value=job.id}
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
function pickAsset(asset:any){const key=picker.value;if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||'';else if(key==='startClipId')chooseClip(asset?.id||'');else form[key]=asset?.id||'';closePicker()}
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
async function deleteOutput(){
if(!selected.value)return
try{
const kind=selected.value.kind==='clip'?'clips/':selected.value.kind==='track'?'tracks/':'stills/'
await $fetch('/api/library/'+kind+selected.value.id,{method:'DELETE'})
await refresh()
}catch(e:any){error.value=e.data?.statusMessage||e.message}
}
async function queue(){
submitting.value=true;error.value=''
try{
if(form.mode==='music'){
await $fetch('/api/generate/music',{method:'POST',body:{engine:'yue2',tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed||'random',folderId:form.folderId,name:music.name}})
await refresh()
return
}
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body:{...form,mode:form.mode==='edit' && parseBatch(batchText.value).length?'iterate':form.mode,hideInput:xaigen,hideOutput:false,promptSections:video.value?form.promptSections:{action:form.promptSections.action},settings:{...form.settings,...resolvedSize.value},batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]}})
form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
await refresh()
}catch(e:any){error.value=e.data?.statusMessage||e.message}
finally{submitting.value=false}
}
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refresh()}
async function upload(event:Event,target:'source'|'hero'){
const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return
uploading.value=true;uploadMessage.value='';error.value=''
const body=new FormData();body.append('image',file);body.append('folderId',form.folderId);body.append('hideInput',String(xaigen))
try{const response=await $fetch<any>('/api/studio-2/stills',{method:'POST',body});library.value=response;Object.assign(form,applyUploadedStill(form,response,target));measured[response.still.id]={width:response.still.width,height:response.still.height};uploadMessage.value=`Loaded ${response.still.name || file.name} as ${target==='hero'?'hero identity':'start still'}.`}
catch(e:any){error.value=e.data?.statusMessage||e.message}
finally{uploading.value=false;input.value=''}
}
async function refresh(){
const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([
$fetch<any[]>('/api/studio-2/jobs'),
$fetch<any>('/api/studio-2/library'),
$fetch<any[]>('/api/studio-2/upscales'),
$fetch<any>('/api/studio-queue?full=1').catch(()=>({jobs:[]}))
])
jobs.value=nextJobs;library.value=nextLibrary;upscales.value=nextUpscales
studioQueue.value=Array.isArray(queue)?queue:(queue?.jobs||[])
const musicRows=studioQueue.value.filter((j:any)=>j.kind==='music' && j.liveJobId && !['complete','done'].includes(j.status))
const liveEntries=await Promise.all(musicRows.map(async(j:any)=>{
try{return [j.liveJobId,await $fetch<any>(`/api/generate/${j.liveJobId}`)] as const}
catch{return [j.liveJobId,null] as const}
}))
const nextLive:Record<string,any>={}
for(const [id,snap] of liveEntries){if(snap)nextLive[id]=snap}
liveMusic.value=nextLive
const fresh=newestOutput(nextJobs,seenOutputs,form.folderId)
for(const job of nextJobs)for(const output of job.outputs||[])seenOutputs.add(output.id)
const existingFresh=family.value.find(a=>a.id===fresh?.id)
const newestTrack=(library.value.tracks||[]).filter((t:any)=>t.folderId===form.folderId).sort((a:any,b:any)=>(b.createdAt||0)-(a.createdAt||0))[0]
// Only auto-select new tracks on Music — never leave a waveform mounted over Generate/Klein/Video.
if(form.mode==='music' && newestTrack && !seenOutputs.has(newestTrack.id)){seenOutputs.add(newestTrack.id);selected.value={...newestTrack,kind:'track'};return}
if(newestTrack) seenOutputs.add(newestTrack.id)
let next=existingFresh || reconcileSelection(selected.value,family.value)
if(form.mode!=='music' && next?.kind==='track') next=family.value.find((a:any)=>a.kind!=='track') || null
selected.value=next
if(fresh || existingFresh) scrollFilmstripToNewest()
else if(selected.value?.id===family.value.at(-1)?.id) scrollFilmstripToNewest()
}
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.startClipId='';loadRevealed(id);if(import.meta.client)form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId)localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
onMounted(async()=>{
let storage;try{storage=localStorage}catch{}
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras(true)
library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';loadRevealed(form.folderId)
await applyIncomingGenerationLoad()
const id=String(route.query.still||route.query.clip||route.query.track||'')
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
if(route.query.task==='music')form.mode='music'
const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()
scrollFilmstripToNewest()
})
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
function randomizeSeed(){form.settings.seedMode='random';form.settings.seed=randomSeed()}
function setPicker(key:string){picker.value=key}
const mobileCtx=computed(()=>({
instanceName,
xaigen,
form,
music,
library:library.value,
stills:stills.value,
tasks,
engineOptions:engineOptions.value,
gpu:gpu.value,
gpuChipLabel:gpuChipLabel.value,
queueCount:queueCount.value,
queueCards:queueCards.value,
focusedJobId:focusedJobId.value,
canClearFinished:canClearFinished.value,
selected:selected.value,
family:family.value,
sourceSlot:sourceSlot.value,
video:video.value,
submitting:submitting.value,
uploading:uploading.value,
uploadMessage:uploadMessage.value,
error:error.value,
batchText:batchText.value,
negativeChips:negativeChips.value,
negativeDraft:negativeDraft.value,
heroSlotLabel:heroSlotLabel.value,
sourceSlotLabel:sourceSlotLabel.value,
slotPairHint:slotPairHint.value,
editSourceRequired:editSourceRequired.value,
pickerLoras:pickerLoras.value,
currentLoraUse:currentLoraUse.value,
loraAliases:{...loraNames.value.aliases,...library.value.loraAliases},
loraTriggers:{...loraNames.value.triggers,...library.value.loraTriggers},
aspectOptions,
stillUrl,
isHidden,
slotHidden,
toggleReveal,
chooseTask,
selectOutput,
deleteOutput,
queue,
upload,
cancel,
focusQueueCard,
clearFinished,
removeNegative,
addNegative,
applyUseAsSource,
applyUseAsHero,
copySelectedPrompt,
loadSelectedJob,
randomizeSeed,
setPicker,
setBatchText:(v:string)=>{batchText.value=v},
setNegativeDraft:(v:string)=>{negativeDraft.value=v},
}))
</script>