Files
aigen/components/studio2/Bench.vue
T

1175 lines
52 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>
<section v-if="photosPlanState.show" class="photos-section" aria-label="Photos">
<div class="photos-head">
<span class="field-label">Photos</span>
<button
v-if="!photosPlanState.handoffReadonly"
type="button"
class="photos-inputs-toggle"
@click="hideInputs=!hideInputs"
>{{ hideInputs ? 'Show inputs' : 'Hide inputs' }}</button>
</div>
<p v-if="photosPlanState.handoffReadonly" class="text-[var(--type-sm)] text-[var(--muted)]">Continues from last frame</p>
<template v-else-if="hideInputs">
<p class="photos-collapsed">
<span>{{ photoFilledCount }} photo{{ photoFilledCount===1 ? '' : 's' }}</span>
<button
v-if="form.photos.length < photosPlanState.max"
type="button"
class="photos-add-link"
@click="hideInputs=false; addPhoto()"
>+ Add photo</button>
</p>
</template>
<template v-else>
<p v-if="!form.photos.length && photosPlanState.emptyHint" class="text-[var(--type-sm)] text-[var(--muted)]">{{ photosPlanState.emptyHint }}</p>
<Studio2AssetSlot
v-for="(card, index) in form.photos"
:key="'photo-'+index+'-'+card.stillId"
:asset="stillById(card.stillId)"
:hidden="false"
:role="card.role"
:role-options="photoRoleOptions"
@pick="picker='photo:'+index"
@remove="removePhoto(index)"
@mention="mentionPhoto(index)"
@update:role="setPhotoRole(index, $event)"
/>
<button
v-if="form.photos.length < photosPlanState.max"
type="button"
class="photos-add"
@click="addPhoto"
>+ Add photo</button>
<p v-if="photoRoleWarning" class="text-[11px] text-[var(--amber)]">{{ photoRoleWarning }}</p>
</template>
</section>
<div v-if="showIdentityLocks" 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>
</div>
<label>
<span class="field-label">Prompt</span>
<textarea
ref="promptArea"
v-model="form.promptSections.action"
class="field-area"
placeholder="Describe the image or the shot."
@select="capturePromptCaret"
@click="capturePromptCaret"
@keyup="capturePromptCaret"
/>
</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">
<div class="stage-chrome" aria-label="Stage controls">
<div class="stage-chrome-top">
<button
type="button"
class="stage-library"
:aria-expanded="libraryOpen ? 'true' : 'false'"
@click="libraryOpen=true"
>Library<span v-if="familyStillCount" class="stage-library-count">{{ familyStillCount }}</span></button>
<button
v-if="selected"
type="button"
class="eye-viewer"
:aria-pressed="eyeOpen"
:aria-label="eyeOpen ? 'Hide output' : 'Show output'"
@click.stop="toggleReveal()"
>
<img :src="eyeOpen ? '/icons/display/eye.svg' : '/icons/display/eye-off.svg'" alt="" width="20" height="20">
</button>
</div>
<div
v-if="selected && selected.kind!=='track'"
class="stage-actions"
aria-label="Selected still actions"
>
<button type="button" @click.stop="applyUseAsSource">Use as photo</button>
<button type="button" @click.stop="copySelectedPrompt">Copy prompt</button>
<button type="button" @click.stop="loadSelectedJob">Load</button>
<button type="button" class="stage-action-danger" @click.stop="deleteOutput">Delete</button>
</div>
</div>
<button
v-if="family.length > 1"
type="button"
class="stage-nav stage-nav-prev"
aria-label="Previous"
@click.stop="stepFamily(-1)"
>‹</button>
<button
v-if="family.length > 1"
type="button"
class="stage-nav stage-nav-next"
aria-label="Next"
@click.stop="stepFamily(1)"
>›</button>
<template v-if="selected && isHidden(selected)">
<p class="placeholder-still">{{ selected.kind==='track' ? 'Track 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">Open Library to pick a still or clip</p>
</template>
</div>
<div v-if="selected?.kind==='clip'" 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>
<Studio2LibraryModal
:open="libraryOpen"
:assets="family"
:selected="selected"
:folder-id="form.folderId"
:preview-hidden="!eyeOpen"
@close="libraryOpen=false"
@select="selectOutput"
@use-as-photo="onLibraryUseAsPhoto"
@copy-prompt="onLibraryCopyPrompt"
@load="onLibraryLoad"
@delete="onLibraryDelete"
@toggle-preview="toggleReveal()"
/>
</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 v-if="card.progress" class="job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
<progress
class="job-progress-bar"
max="100"
:value="card.progress.indeterminate ? undefined : card.progress.percent"
/>
<span v-if="card.elapsed != null" class="job-elapsed">{{ card.elapsed }}</span>
</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 a photo" 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 a photo</h2><button type="button" @click="closePicker">Cancel</button></div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<label class="slot-upload">Upload from disk
<input type="file" accept="image/*" class="sr-only" :disabled="uploading" @change="uploadPickerFile">
</label>
<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>
<p v-if="!pickerAssets.length" class="text-[var(--type-sm)] text-[var(--muted)]">No stills in this folder yet. Upload from disk above.</p>
</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,useAsSource,hydrateStudio2Job} from '~/shared/studio2/input.mjs'
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
import {queueJobs,jobStageLabel,jobProgressView,elapsedSeconds} 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 {
photosPlan,
PHOTO_ROLE_LABEL,
mentionPhrase,
insertMention,
duplicatePhotoRole,
applyPhotosToRequest,
photosFromRequest
} from '~/shared/studio2/photos.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 libraryOpen=ref(false)
const eyeOpen=ref(!xaigen)
const hideInputs=ref(!!xaigen)
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:[],photos:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
const promptArea=ref<HTMLTextAreaElement|null>(null)
const promptCaret=ref({start:0,end:0})
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 plan=photosPlan({mode,engine})
if(!plan.show){form.photos=[];form.imageAId='';form.imageBId='';if(engine==='qwen21')form.identityStillId='';return}
const allowed=new Set(plan.roles)
form.photos=form.photos
.filter((p:any)=>p?.stillId)
.slice(0,plan.max)
.map((p:any)=>({stillId:p.stillId,role:allowed.has(p.role)?p.role:plan.defaultRole}))
syncPhotosBindings()
})
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.imageBId='';form.photos=[];form.startClipId='';loadRevealed(id);if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
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 familyStillCount=computed(()=>family.value.filter((a:any)=>a.kind==='still').length)
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||form.photos?.[0]?.stillId)))
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 photosPlanState=computed(()=>photosPlan({mode:form.mode,engine:form.engine}))
const photoRoleOptions=computed(()=>(photosPlanState.value.roles||[]).map((id:string)=>({id,label:PHOTO_ROLE_LABEL[id]||id})))
const photoFilledCount=computed(()=>(form.photos||[]).filter((p:any)=>p?.stillId).length)
const photoRoleWarning=computed(()=>{
const dup=duplicatePhotoRole(form.photos)
return dup?`Two photos are set to the same job (${dup}). Change one.`:''
})
const showIdentityLocks=computed(()=>form.engine!=='qwen21' && form.engine!=='krea' && !['music'].includes(form.mode))
const queueCards=computed(()=>{
const cards:any[]=[]
const now=Date.now()
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
const secs=elapsedSeconds(job,now)
const progress=jobProgressView(job)
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:jobStageLabel(state),
progress,
elapsed:secs>0?`${Math.floor(secs/60)}m ${secs%60}s`:'',
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 stillById(id:string){return id?stills.value.find((s:any)=>s.id===id)||null:null}
/** Photos array is source of truth — never resurrect cards from leftover imageAId. */
function syncPhotosBindings(){
const filled=(form.photos||[]).filter((p:any)=>p?.stillId)
const next=applyPhotosToRequest({
...form,
photos:filled,
imageAId:'',
imageBId:'',
identityStillId:'',
})
// Keep empty draft cards in the UI; only overwrite filled bindings.
const drafts=(form.photos||[]).filter((p:any)=>!p?.stillId)
form.photos=[...(next.photos||[]),...drafts]
form.imageAId=next.imageAId||''
form.imageBId=next.imageBId||''
form.identityStillId=form.engine==='qwen21'?'':(next.identityStillId||'')
}
function addPhoto(){
const plan=photosPlanState.value
if(!plan.show||plan.handoffReadonly)return
if(form.photos.length>=plan.max)return
form.photos.push({stillId:'',role:plan.defaultRole||'change'})
picker.value='photo:'+(form.photos.length-1)
}
function removePhoto(index:number){
if(!Number.isInteger(index)||index<0||index>=form.photos.length)return
form.photos.splice(index,1)
// Explicit empty photos array clears imageAId / imageBId / identityStillId.
syncPhotosBindings()
}
function setPhotoRole(index:number,role:string){
if(!form.photos[index])return
form.photos[index].role=role
syncPhotosBindings()
}
function setPhotoStill(index:number,stillId:string){
if(!Number.isInteger(index)||!form.photos[index])return
if(!stillId){
form.photos.splice(index,1)
}else{
form.photos[index].stillId=stillId
}
syncPhotosBindings()
}
function discardEmptyPhotoDraft(index:number){
if(!Number.isInteger(index)||!form.photos[index])return
if(!form.photos[index].stillId)form.photos.splice(index,1)
}
function capturePromptCaret(){
const el=promptArea.value
if(!el)return
promptCaret.value={start:el.selectionStart||0,end:el.selectionEnd||0}
}
function mentionPhoto(index:number){
const card=form.photos[index]
if(!card?.stillId)return
const phrase=mentionPhrase(card.role,index)
const el=promptArea.value
const start=el?.selectionStart ?? promptCaret.value.start
const end=el?.selectionEnd ?? promptCaret.value.end
const next=insertMention(form.promptSections.action||'',phrase,start,end)
form.promptSections.action=next.text
nextTick(()=>{
const box=promptArea.value
if(!box)return
box.focus()
box.setSelectionRange(next.selectionStart,next.selectionEnd)
promptCaret.value={start:next.selectionStart,end:next.selectionEnd}
})
}
function applyUseAsSource(){
if(!selected.value||selected.value.kind==='track')return
const plan=photosPlanState.value
if(!plan.show||plan.handoffReadonly){
Object.assign(form,useAsSource(form,selected.value))
uploadMessage.value=`Using ${assetLabel(selected.value)}.`
return
}
if(form.photos.length>=plan.max){
form.photos[form.photos.length-1]={stillId:selected.value.id,role:plan.defaultRole||form.photos.at(-1)?.role||'change'}
}else{
form.photos.push({stillId:selected.value.id,role:plan.defaultRole||'change'})
}
syncPhotosBindings()
uploadMessage.value=`Added ${assetLabel(selected.value)} to Photos.`
}
async function copySelectedPrompt(){
const text=selected.value?.studio2?.prompt
|| selected.value?.studio2?.compiledPrompt
|| selected.value?.prompt
|| selected.value?.studio2?.promptSections?.action
|| ''
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||[]
form.photos=photosFromRequest({...next,photos:saved.photos})
syncPhotosBindings()
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)
}
function focusQueueCard(card:any){
focusedJobId.value=card.id
if(card.job?.outputs?.length)focusJob(card.job)
}
function isHidden(asset:any){
if(!asset?.id)return false
if(isClassic.value){
const input=asset.role==='input'
return input?classicHideInput.value:classicHideOutput.value
}
// Session chrome: eye gates the stage preview across neighbors.
return !eyeOpen.value
}
function pickerThumbHidden(asset:any){
if(pickerRevealAll.value)return false
if(!asset?.id)return false
if(isClassic.value)return classicHideInput.value
if(xaigen) return previewHidden(asset,revealed.value,xaigen)
return false
}
function slotHidden(_id:string){
// Photos inspector uses hideInputs — never the output eye.
return hideInputs.value
}
function toggleReveal(_id?:string){
eyeOpen.value=!eyeOpen.value
}
async function deleteOutput(){
if(!selected.value)return
const label=assetLabel(selected.value)
if(import.meta.client && !window.confirm(`Delete ${label}?`))return
try{
const kind=selected.value.kind==='clip'?'clips/':selected.value.kind==='track'?'tracks/':'stills/'
const id=selected.value.id
await $fetch('/api/library/'+kind+id,{method:'DELETE'})
await refresh()
}catch(e:any){error.value=e.data?.statusMessage||e.message}
}
function stepFamily(delta:number){
const list=family.value
if(list.length < 2)return
const i=list.findIndex((a:any)=>a.id===selected.value?.id)
const from=i < 0 ? 0 : i
const next=list[(from + delta + list.length) % list.length]
if(next) selectOutput(next)
}
function onLibraryUseAsPhoto(item:any){
if(item)selectOutput(item)
applyUseAsSource()
}
function onLibraryCopyPrompt(item:any){
if(item)selectOutput(item)
void copySelectedPrompt()
}
function onLibraryLoad(item:any){
if(item)selectOutput(item)
loadSelectedJob()
}
function onLibraryDelete(item:any){
if(item)selectOutput(item)
void deleteOutput()
}
function onStageHotkey(e:KeyboardEvent){
if(e.metaKey||e.ctrlKey||e.altKey)return
const t=e.target as HTMLElement|null
if(t && (t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable))return
if(libraryOpen.value||picker.value)return
if(e.key==='l' || e.key==='L'){
e.preventDefault()
libraryOpen.value=true
return
}
if(e.key==='ArrowLeft'){
e.preventDefault()
stepFamily(-1)
return
}
if(e.key==='ArrowRight'){
e.preventDefault()
stepFamily(1)
}
}
function closePicker(){
const key=picker.value
if(key.startsWith('photo:'))discardEmptyPhotoDraft(Number(key.slice(6)))
picker.value=''
pickerRevealAll.value=false
}
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 if(key.startsWith('photo:')){
const index=Number(key.slice(6))
if(asset?.id)setPhotoStill(index,asset.id)
else discardEmptyPhotoDraft(index)
}else if(key) form[key]=asset?.id||''
picker.value=''
pickerRevealAll.value=false
}
async function uploadPickerFile(event:Event){
const input=event.target as HTMLInputElement
const file=input.files?.[0]
if(!file)return
const key=picker.value
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
measured[response.still.id]={width:response.still.width,height:response.still.height}
if(key.startsWith('photo:')){
setPhotoStill(Number(key.slice(6)),response.still.id)
picker.value=''
pickerRevealAll.value=false
}else{
Object.assign(form,applyUploadedStill(form,response,'source'))
}
uploadMessage.value=`Loaded ${response.still.name || file.name}.`
}catch(e:any){error.value=e.data?.statusMessage||e.message}
finally{uploading.value=false;input.value=''}
}
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
measured[response.still.id]={width:response.still.width,height:response.still.height}
const plan=photosPlanState.value
if(plan.show && !plan.handoffReadonly){
const role=target==='hero'
?(plan.roles.includes('person')?'person':plan.defaultRole)
:plan.defaultRole
if(form.photos.length>=plan.max)form.photos[form.photos.length-1]={stillId:response.still.id,role}
else form.photos.push({stillId:response.still.id,role:role||'change'})
syncPhotosBindings()
}else{
Object.assign(form,applyUploadedStill(form,response,target))
}
uploadMessage.value=`Loaded ${response.still.name || file.name}.`
}
catch(e:any){error.value=e.data?.statusMessage||e.message}
finally{uploading.value=false;input.value=''}
}
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.'}}
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
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(photoRoleWarning.value){error.value=photoRoleWarning.value;return}
syncPhotosBindings()
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
const body=applyPhotosToRequest({
...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):[]
})
const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body})
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 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
}
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.imageBId='';form.photos=[];form.startClipId='';loadRevealed(id);if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')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()
if(import.meta.client)window.addEventListener('keydown',onStageHotkey)
})
onBeforeUnmount(()=>{
disposed=true;clearTimeout(timer);clearInterval(clock)
if(import.meta.client)window.removeEventListener('keydown',onStageHotkey)
})
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,
familyStillCount:familyStillCount.value,
libraryOpen:libraryOpen.value,
eyeOpen:eyeOpen.value,
hideInputs:hideInputs.value,
photoFilledCount:photoFilledCount.value,
openLibrary:()=>{libraryOpen.value=true},
closeLibrary:()=>{libraryOpen.value=false},
toggleHideInputs:()=>{hideInputs.value=!hideInputs.value},
showInputsAndAddPhoto:()=>{hideInputs.value=false;addPhoto()},
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,
photosPlanState:photosPlanState.value,
photoRoleOptions:photoRoleOptions.value,
photoRoleWarning:photoRoleWarning.value,
showIdentityLocks:showIdentityLocks.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,
stillById,
toggleReveal,
chooseTask,
selectOutput,
stepFamily,
deleteOutput,
queue,
upload,
cancel,
focusQueueCard,
clearFinished,
removeNegative,
addNegative,
addPhoto,
removePhoto,
setPhotoRole,
mentionPhoto,
applyUseAsSource,
copySelectedPrompt,
loadSelectedJob,
onLibraryUseAsPhoto,
onLibraryCopyPrompt,
onLibraryLoad,
onLibraryDelete,
randomizeSeed,
setPicker,
setBatchText:(v:string)=>{batchText.value=v},
setNegativeDraft:(v:string)=>{negativeDraft.value=v},
}))
</script>