@@ -8,17 +8,17 @@
< 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 > < div v-if = "xaigen" class="flex gap-4"><label><input v-model="form.hideInput" type="checkbox"> Hide input stills</label><label><input v-model="hideOutput" type="checkbox" > Hide output preview < / label > < / div > < / div >
< div class = "flex flex-wrap justify-between gap-3 rounded-xl border border-white/10 px-4 py-2 text-sm" > < Studio2GpuStatus :status = "gpu" / > < div v-if = "xaigen" class="flex gap-4"><label><input v-model="form.hideInput" type="checkbox"> Hide input stills</label><label><input v-model="hideOutput" type="checkbox" > Hide output preview < / label > < / div > < / 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" >
< p v-if = "selected && xaigen && (hideOutput || (selected.role==='input' && form.hideInput))" class="text-zinc-400" > Output loaded · preview hidden < / p >
< video v-else-if = "selected?.kind==='clip'" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full object-contain" / >
< video v-else-if = "selected?.kind==='clip'" :key="selected.id" :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 v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="Object.assign(form,useAsSource(form,selected))">Use as source still</button><button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="Object.assign(form,useAsHero(form,selected))">Use as hero</button><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="detailsOpen=true">Details</button><button class="rounded-full border border-red-400/40 px-4 py-2 text-sm text-red-200" @click="deleteOutput">Delete</button><VideoUpscaleControl v-if="selected.kind==='clip'" :clip-id="selected.id" / > < / div >
< div v-if = "selected" class="flex flex-wrap items-start gap-3"><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="restore(selected)">Use as input</button><button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="Object.assign(form,useAsSource(form,selected))">Use as source still</button><button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="Object.assign(form,useAsHero(form,selected))">Use as hero</button><button class="rounded-full border border-white/20 px-4 py-2 text-sm" @click="detailsOpen=true">Details</button><button class="rounded-full border border-red-400/40 px-4 py-2 text-sm text-red-200" @click="deleteOutput">Delete</button><VideoUpscaleControl v-if="selected.kind==='clip'" :clip-id="selected.id" queue-only @complete="refresh" / > < / 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'"><span v-if="previewHidden(o,xaigen,form.hideInput,hideOutput)" class="flex h-20 w-24 items-center justify-center p-1 text-xs text-zinc-400">{{ o.role==='input'?'Still loaded · preview hidden':'Output hidden' }}</span><img v-else :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 aria -label = " Source " class = "rounded-2xl border border-white/10 p-3" > < h2 class = "mb-3 text-xs uppercase tracking-wider text-zinc-400" > Source < / h2 > < div class = "flex flex-wrap gap-4" >
< Studio2AssetSlot label = "Hero" :asset = "stills.find((a:any)=>a.id===form.identityStillId)" :hidden = "inputHidden" upload :disabled = "uploading" @pick ="picker='identityStillId'" @upload ="upload($event,'hero')" / >
@@ -40,6 +40,8 @@
< p v-if = "form.mode==='extend'" class="text-sm text-amber-200" > {{ form.startClipId ? ' Extend · ' + ( clips.find ( ( c : any ) = > c . id === form . startClipId ) ? . name || form . startClipId ) + ' · last frame' : 'Select the clip in the family strip or Previous clip.' } } < / p >
< p v-if = "!video && form.engine==='krea'" class="text-sm text-zinc-400" > Krea does not attach the hero identity reference. < / 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 >
< h2 class = "text-sm font-semibold text-zinc-300" > Job queue < / h2 >
< div v-for = "job in upscales.filter(j=>j.folderId===form.folderId && ['queued','running'].includes(j.status))" :key="job.id" class="space-y-2 rounded-xl border border-amber-300/25 bg-zinc-900 p-4 text-sm" role="status"><div class="flex justify-between gap-3"><strong>Video upscale</strong><span class="capitalize text-amber-200">{{ job.status }}</span></div><p class="truncate text-zinc-300">{{ job.name }}</p><p class="text-xs text-zinc-400">{{ job.width?job.width+' × '+job.height+' · ':'' }}{{ Math.round((job.elapsedMs||0)/1000) }}s elapsed</p><progress class="h-2 w-full accent-amber-300" max="100" :value="job.progress || 0" /><p class="text-xs text-zinc-400" > {{ job.message }} < / p > < / div >
< Studio2JobProgress v-for = "job in benchJobs" :key="job.id" :job="job" :now="now" @select="focusJob(job)" @cancel="cancel(job)" / >
< button v-if = "folderJobs.some(j=>['complete','failed','cancelled'].includes(j.state))" class="text-sm text-zinc-400" @click="clearFinished" > Clear finished < / button >
< / section >
@@ -59,6 +61,7 @@
import { compilePrompt , sectionsFrom } from '~/shared/studio2/contracts.mjs'
import { aspectOptions , resolveSize , bindClip , restoreAsset , extendSelected , changeSource , restoreAll , useAsHero , useAsSource } from '~/shared/studio2/input.mjs'
import { folderAssets , newestOutput , assetLabel , reconcileSelection , activeJobs , previewHidden } from '~/shared/studio2/viewer.mjs'
import { gpuStatus } from '~/shared/studio2/status.mjs'
import { applyUploadedStill } from '~/shared/studio2/upload.mjs'
const xaigen = useRuntimeConfig ( ) . public . studio === 'xaigen' , hideOutput = ref ( false ) , picker = ref ( '' )
const inputHidden = computed ( ( ) => xaigen && form . hideInput )
@@ -66,7 +69,8 @@ const uploading=ref(false),uploadMessage=ref('')
import { parseBatch , stylePrompt } from '~/shared/studio2/styles.mjs'
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 loraNames = ref < any > ( { image : [ ] , video : [ ] } ) , now = ref ( Date . now ( ) ) , healthState = ref < any > ( null ) , upscales = ref < any [ ] > ( [ ] )
const gpu = computed ( ( ) => gpuStatus ( jobs . value , healthState . value , upscales . value ) )
const library = ref < any > ( { folders : [ ] , stills : [ ] , clips : [ ] } ) , jobs = ref < any [ ] > ( [ ] ) , selected = ref < any > ( null )
const form = reactive < any > ( { mode : 'generate' , engine : 'flux' , imageStyles : { positive : [ ] , negative : [ ] } , 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 ( )
@@ -118,8 +122,8 @@ async function upload(event:Event,target:'source'|'hero'){
}
async function refresh ( ) {
const [ nextJobs , nextLibrary ] = await Promise . all ( [ $fetch < any [ ] > ( '/api/studio-2/jobs' ) , $fetch < any > ( '/api/studio-2/library' ) ] )
jobs . value = nextJobs ; library . value = nextLibrary
const [ nextJobs , nextLibrary , nextUpscales ] = await Promise . all ( [ $fetch < any [ ] > ( '/api/studio-2/jobs' ) , $fetch < any > ( '/api/studio-2/library' ) , $fetch < any [ ] > ( '/api/studio-2/upscales' ) ] )
jobs . value = nextJobs ; library . value = nextLibrary ; upscales . value = nextUpscales
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 )
@@ -131,6 +135,6 @@ watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId)localSt
watch ( hideOutput , value => { if ( import . meta . client ) localStorage . setItem ( 'studio2-hide-output' , String ( value ) ) } )
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 = xaigen && localStorage . getItem ( 'studio2-hide-input' ) === 'true' ; hideOutput . value = xaigen && localStorage . getItem ( 'studio2-hide-output' ) === 'true' ; clock = setInterval ( ( ) => { now . value = Date . now ( ) } , 1000 ) ; void loadLoras ( ) ; library . value = await $fetch ( '/api/studio-2/library' ) ; 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 ( ) } )
onMounted ( async ( ) => { form . settings . seed = randomSeed ( ) ; form . hideInput = xaigen && localStorage . getItem ( 'studio2-hide-input' ) === 'true' ; hideOutput . value = xaigen && localStorage . getItem ( 'studio2-hide-output' ) === 'true' ; clock = setInterval ( ( ) => { now . value = Date . now ( ) } , 1000 ) ; void loadLoras ( ) ; library . value = await $fetch ( '/api/studio-2/library' ) ; 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' ) ; 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 ) } )
< / script >