Files
aigen/components/studio2/Bench.vue
T

113 lines
18 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>
<main class="w-full min-w-0 space-y-5 p-4 md:p-6 xl:p-8">
<header class="flex flex-wrap items-center justify-between gap-4">
<div><h1 class="font-display text-3xl font-bold">Studio 2</h1><p class="text-sm text-zinc-400">One hero. Every shot.</p></div>
<div class="flex flex-wrap items-center gap-3">
<label class="text-sm">Folder <select v-model="form.folderId" class="rounded-lg bg-zinc-800 p-2"><option v-for="f in library.folders" :value="f.id" :key="f.id">{{ f.name }}</option></select></label>
<label class="text-sm">Hero identity <select v-model="form.identityStillId" class="max-w-52 rounded-lg bg-zinc-800 p-2"><option value="">Choose hero</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ assetLabel(s) }}</option></select></label>
<label class="text-sm"><input v-model="form.lockFace" type="checkbox"> Lock face <span class="block text-xs text-zinc-500">Face, hair, glasses, body</span></label><label class="text-sm"><input v-model="form.lockOutfit" type="checkbox"> Lock outfit <span class="block text-xs text-zinc-500">Wardrobe from hero</span></label><span v-if="form.identityStillId && form.hideInput" class="text-xs text-zinc-400">Still loaded · preview hidden</span>
<img v-if="form.identityStillId && !form.hideInput" :src="stillUrl(form.identityStillId)" class="h-12 w-12 rounded-lg object-cover" alt="Sticky hero identity">
<NuxtLink to="/library" class="text-sm text-amber-200">Library</NuxtLink><NuxtLink to="/" class="text-sm text-zinc-400">Studio 1</NuxtLink>
</div>
</header>
<div class="flex flex-wrap justify-between gap-3 rounded-xl border border-white/10 px-4 py-2 text-sm"><span>GPU · {{ gpuLabel }}</span><span>{{ activeJob ? `${activeJob.request.promptSections.action.slice(0,60)} · ${activeJob.state}` : 'No active Studio 2 job' }}</span><label><input v-model="form.hideInput" type="checkbox"> Hide input stills</label></div>
<nav class="flex flex-wrap gap-2"><button v-for="task in tasks" :key="task" class="rounded-full border px-4 py-2 capitalize" :class="form.mode===task?'border-amber-300 bg-amber-300 text-black':'border-white/15'" @click="chooseTask(task)">{{ task }}</button></nav>
<div class="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(24rem,2fr)] 2xl:gap-8">
<section class="min-w-0 space-y-4">
<div class="flex min-h-80 items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black">
<video v-if="selected?.kind==='clip'" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full object-contain" />
<img v-else-if="selected" :src="stillUrl(selected.id)" class="max-h-[65vh] max-w-full object-contain" alt="Current output">
<p v-else class="text-zinc-500">Select a result to view it here</p>
</div>
<div v-if="selected" class="flex flex-wrap gap-3"><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="restore(selected)">Use as input</button><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="detailsOpen=true">Details</button><VideoUpscaleControl v-if="selected.kind==='clip'" :clip-id="selected.id" /></div>
<div class="flex min-w-0 max-w-full gap-2 overflow-x-auto" aria-label="Current family outputs"><button v-for="o in family" :key="o.id" @click="selectOutput(o)" class="shrink-0 rounded-lg border p-1" :class="selected?.id===o.id?'border-amber-300':'border-white/10'"><img :src="o.kind==='clip'?`/api/library/clips/${o.id}/thumb`:stillUrl(o.id)" class="h-20 w-24 rounded object-cover" alt="Family output"></button></div>
<section class="rounded-2xl border border-white/10 bg-black p-3" aria-label="Input preview"><h2 class="mb-2 text-sm text-zinc-400">Input preview · {{ inputStill ? assetLabel(inputStill) : form.startClipId ? 'Previous clip · last frame' : 'No start still' }}</h2><p v-if="form.hideInput && (inputStill || form.startClipId)" class="p-6 text-zinc-400">Still loaded · preview hidden</p><img v-else-if="inputStill" :src="stillUrl(inputStill.id)" class="max-h-56 w-full object-contain" alt="Selected source still"><p v-else-if="form.startClipId" class="p-6 text-zinc-400">Previous clip loaded · its last frame will be used</p><p v-else class="p-6 text-zinc-500">Choose a source / start still</p></section>
<div class="grid gap-3 sm:grid-cols-2">
<label>Source / start still<select v-model="form.imageAId" @change="sourceChanged" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">None</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ assetLabel(s) }}</option></select></label>
<label v-if="form.mode==='compose'">Second still<select v-model="form.imageBId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Required</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ assetLabel(s) }}</option></select></label>
<label v-if="form.mode==='refine'">Mask<select v-model="form.maskId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Required: white area to refine</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ assetLabel(s) }}</option></select></label>
<label v-if="form.mode==='extend'">Previous clip<select :value="form.startClipId" @change="chooseClip(($event.target as HTMLSelectElement).value)" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Choose a clip</option><option v-for="c in clips" :key="c.id" :value="c.id">{{ c.name || c.prompt?.slice(0,50) }}</option></select></label>
<label v-if="video">Optional end still<select v-model="form.endStillId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">None</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ assetLabel(s) }}</option></select></label>
<label class="text-sm">Upload start still<input type="file" accept="image/*" :disabled="uploading || !form.folderId" class="mt-2 block text-xs" @change="upload($event,'source')"></label><label class="text-sm">Upload hero<input type="file" accept="image/*" :disabled="uploading || !form.folderId" class="mt-2 block text-xs" @change="upload($event,'hero')"></label><p v-if="uploading || uploadMessage" role="status" class="text-sm text-amber-200">{{ uploading ? 'Uploading image…' : uploadMessage }}</p>
</div>
<details v-if="video" class="rounded-xl border border-white/10 p-3"><summary>Add Guide stills</summary><div v-for="(g,i) in form.guides" :key="i" class="mt-2 flex gap-2"><select v-model="g.stillId" class="min-w-0 flex-1 bg-zinc-800 p-2"><option value="">Choose guide</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ assetLabel(s) }}</option></select><input v-model.number="g.frame" type="number" min="1" aria-label="Guide frame" class="w-20 bg-zinc-800 p-2"><button @click="form.guides.splice(i,1)">Remove</button></div><button class="mt-3 text-amber-200" @click="form.guides.push({stillId:'',frame:24})">Add guide</button></details>
</section>
<section class="min-w-0 space-y-4">
<div class="flex justify-between"><h2 class="text-xl font-semibold">Prompt</h2><button class="text-sm text-amber-200" @click="advanced=!advanced">{{ advanced?'Simple':'Advanced' }}</button></div>
<label class="block">Action<textarea v-model="form.promptSections.action" rows="4" class="mt-1 w-full rounded-xl bg-zinc-900 p-3" placeholder="What happens in this shot?" /></label>
<Studio2PromptControls v-if="video" v-model="form.promptSections" />
<label class="block">Locks<textarea v-model="form.promptSections.locks" rows="2" class="mt-1 w-full rounded-xl bg-zinc-900 p-3" /></label>
<details class="rounded-xl border border-white/10 p-3"><summary>{{ video?'More shots':'Iteration prompts' }}</summary><textarea v-model="batchText" rows="5" class="mt-2 w-full rounded-xl bg-zinc-900 p-3" placeholder="One action prompt per line. Hero stays attached to every output." /></details>
<button class="text-sm text-amber-200" @click="settingsOpen=true">Generation settings</button>
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
<button :disabled="submitting" @click="queue" class="w-full rounded-2xl bg-amber-300 p-4 font-semibold text-black disabled:opacity-50">{{ submitting?'Queuing…':'Queue' }}</button>
<Studio2JobProgress v-for="job in folderJobs.slice(0,6)" :key="job.id" :job="job" :now="now" @select="focusJob(job)" @cancel="cancel(job)" />
<details v-if="advanced"><summary>Compiled prompt</summary><pre class="whitespace-pre-wrap text-xs text-zinc-400">{{ compiled }}</pre></details>
</section>
</div>
<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-white/15 bg-zinc-950 p-6"><button class="float-right" @click="settingsOpen=false;detailsOpen=false">Close</button>
<template v-if="settingsOpen"><h2 class="mb-6 text-xl font-semibold">Generation settings</h2><label class="mb-4 block">Engine<select v-model="form.engine" class="mt-1 w-full rounded bg-zinc-800 p-2"><template v-if="video"><option value="minimax">MiniMax H3</option></template><template v-else><option value="flux">Flux Klein 9B Base</option><option value="krea" :disabled="['edit','compose','iterate'].includes(form.mode)">Krea 2 (Generate / Refine)</option></template></select></label><AspectRatioPick v-model="form.settings.aspect" heading="Aspect" :options="aspectOptions" /><p class="my-3 text-sm text-zinc-400">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding, never stretch</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="mt-1 w-full rounded bg-zinc-800 p-2"></label><label class="mb-4 block">Seed · {{ form.settings.seedMode==='locked'?'Locked':'Random each job' }}<input :value="form.settings.seed" type="number" min="0" max="2147483647" class="mt-1 w-full rounded bg-zinc-800 p-2" @input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'"></label><button class="mb-4 text-sm text-amber-200" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">Random</button><label v-if="video"><input v-model="form.settings.turbo" type="checkbox"> Turbo</label><LoraSelect v-model="form.settings.loraStack" class="mt-5" :names="video?loraNames.video:loraNames.image" :engine="video?'':form.engine" :clip-strength="true" @insert-trigger="form.promptSections.action += ' '+$event" /><button class="mt-2 text-sm text-amber-200" @click="loadLoras">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>
</main>
</template>
<script setup lang="ts">
import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs'
import {aspectOptions,resolveSize,bindClip,restoreAsset,extendSelected} from '~/shared/studio2/input.mjs'
import {folderAssets,newestOutput,assetLabel} from '~/shared/studio2/viewer.mjs'
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
const uploading=ref(false),uploadMessage=ref('')
import {randomSeed} from '~/shared/studio2/seed.mjs'
const measured=reactive<Record<string,{width:number;height:number}>>({}),seenOutputs=new Set<string>()
const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),gpuLabel=ref('Checking…')
const library=ref<any>({folders:[],stills:[],clips:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
const form=reactive<any>({mode:'generate',engine:'flux',lockFace:true,lockOutfit:true,hideInput:false,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 route=useRoute()
const tasks=['generate','edit','compose','refine','iterate','video','extend'],advanced=ref(false),settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('')
const video=computed(()=>['video','extend'].includes(form.mode)),stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId)),clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId)),compiled=computed(()=>compilePrompt(form.promptSections,video.value,form))
const stillUrl=(id:string)=>`/api/library/stills/${id}`
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
const activeJob=computed(()=>folderJobs.value.find(j=>!['complete','failed','cancelled'].includes(j.state)))
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))
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)})
function chooseTask(task:string){if(task==='extend'){Object.assign(form,extendSelected(form,selected.value?.kind==='clip'?selected.value:null));return}const was=video.value;form.mode=task;form.startClipId='';if(video.value!==was){form.engine=video.value?'minimax':'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4}}
function chooseClip(id:string){if(!id){form.startClipId='';return}const clip=clips.value.find((c:any)=>c.id===id);if(clip)Object.assign(form,bindClip(form,clip))}
function selectOutput(item:any){selected.value=item}
function sourceChanged(){form.startClipId='';if(form.mode==='extend')form.mode='video';form.settings.aspect='auto'}
function restore(item:any){Object.assign(form,restoreAsset(form,item));form.guides=form.guides||[];batchText.value=''}
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null)}
async function loadLoras(){loraNames.value=await $fetch('/api/loras').catch(()=>({image:[],video:[]}))}
async function queue(){submitting.value=true;error.value='';try{if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed();const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body:{...form,settings:{...form.settings,...resolvedSize.value},batch:batchText.value.split('\n').map(s=>s.trim()).filter(Boolean)}});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(form.hideInput))
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]=await Promise.all([$fetch<any[]>('/api/studio-2/jobs'),$fetch<any>('/api/library?light=1')])
jobs.value=nextJobs;library.value=nextLibrary
const fresh=newestOutput(nextJobs,seenOutputs,form.folderId)
for(const job of nextJobs)for(const output of job.outputs||[])seenOutputs.add(output.id)
if(fresh)selectOutput(family.value.find(a=>a.id===fresh.id)||fresh)
else if(!selected.value && family.value.length)selectOutput(family.value.at(-1))
}
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.startClipId='';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)})
watch(()=>form.hideInput,value=>{if(import.meta.client)localStorage.setItem('studio2-hide-input',String(value))})
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
onMounted(async()=>{form.settings.seed=randomSeed();form.hideInput=localStorage.getItem('studio2-hide-input')==='true';clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras();library.value=await $fetch('/api/library?light=1');form.folderId=library.value.folders[0]?.id||'';const id=String(route.query.still||route.query.clip||'');const asset=[...library.value.stills,...library.value.clips].find((a:any)=>a.id===id);if(asset){selected.value={...asset,kind:route.query.clip?'clip':'still'};if(route.query.use==='1')restore(selected.value)};const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');gpuLabel.value=health.comfy?.busy?'Busy':health.comfy?.ok?'Ready':'Sleeping / unavailable'}catch(e:any){error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()})
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
</script>