Files
aigen/components/studio2/Bench.vue
T

676 lines
35 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="Hero still" :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="form.mode==='extend'?'Previous clip':'Start still'" :asset="sourceSlot" :hidden="slotHidden(form.mode==='extend'?form.startClipId:form.imageAId)" upload :disabled="uploading" @pick="picker=form.mode==='extend'?'startClipId':'imageAId'" @upload="upload($event,'source')" />
<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 source as &lt;image1&gt;, not Klein hero-ref. No locks or LoRA stack.</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>
<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>
<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" aria-label="Current family outputs">
<div
v-for="o in family"
:key="o.id"
class="tile"
:data-active="selected?.id===o.id ? 'true' : undefined"
>
<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 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="picker=''">
<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="picker=''">Close</button></div>
<button type="button" class="mb-4 text-[var(--type-sm)] text-[var(--muted)]" @click="pickAsset(null)">Clear slot</button>
<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="isHidden(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} 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'
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 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 form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[...defaultNegatives]},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'16:9',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=25
form.settings.cfg=1
form.lockFace=false
form.lockOutfit=false
form.settings.loraStack=[]
}
})
watch(()=>[form.mode,form.engine],([mode,engine])=>{
if(engine==='qwen21' && mode==='edit'){
form.settings.steps=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 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)}}
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 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 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||'';picker.value=''}
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
}
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)
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()
})
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,
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,
randomizeSeed,
setPicker,
setBatchText:(v:string)=>{batchText.value=v},
}))
</script>