113 lines
18 KiB
Vue
113 lines
18 KiB
Vue
<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>
|