@@ -5,26 +5,29 @@
< div class = "flex flex-wrap items-center gap-3" >
< 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" > 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" > {{ s.name | | s.filename }} < / 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" > {{ s.name | | s.filename }} < / option > < / select > < / label >
< img v-if = "form.identityStillId" :src="stillUrl(form.identityStillId)" class="h-12 w-12 rounded-lg object-cover" alt="Sticky hero identity" >
< 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 >
< NuxtLink to = "/library" class = "text-sm text-amber-200" > Library < / NuxtLink > < NuxtLink to = "/" class = "text-sm text-zinc-400" > Studio 1 < / NuxtLink >
< / div >
< / div >
< / header >
< / 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 >
< 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 gap-6 lg:grid-cols-[1.3fr_1fr]" >
< div class = "grid gap-6 lg:grid-cols-[1.3fr_1fr]" >
< section class = "space-y-4" >
< section class = "space-y-4" >
< div class = "flex min-h-80 items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black" >
< 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" / >
< video v-if = "selected?.kind==='clip'" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full" / >
< img v-else-if = "selected" :src="stillUrl(selected.id)" class="max-h-[65vh] object-contain" alt="Current output" >
< img v-else-if = "selected" :src="stillUrl(selected.id)" class="max-h-[65vh] object-contain" alt="Current output" >
< p v-else-if = "form.imageAId && form.hideInput" class="text-zinc-400" > Still loaded · preview hidden < / p >
< img v-else-if = "form.imageAId" :src="stillUrl(form.imageAId)" class="max-h-[65vh] object-contain" alt="Source composition" >
< img v-else-if = "form.imageAId" :src="stillUrl(form.imageAId)" class="max-h-[65vh] object-contain" alt="Source composition" >
< p v-else class = "text-zinc-500" > Your next scene starts here < / p >
< p v-else class = "text-zinc-500" > Your next scene starts here < / p >
< / div >
< / div >
< div v-if = "selected" class="flex 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 v-if = "selected" class="flex 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 gap-2 overflow-x-auto" aria -label = " Current family outputs " > < button v-for = "o in family" :key="o.id" @click="selected=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 >
< div class = "flex 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 >
< div class = "grid gap-3 sm:grid-cols-2" >
< div class = "grid gap-3 sm:grid-cols-2" >
< label > Source / start still < select v-model = "form.imageAId" 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" > {{ s.name | | s.filename }} < / option > < / select > < / label >
< label > Source / start still < select v-model = "form.imageAId" 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" > {{ s.name | | s.filename }} < / 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" > {{ s.name | | s.filename }} < / 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" > {{ s.name | | s.filename }} < / 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" > {{ s.name | | s.filename }} < / 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" > {{ s.name | | s.filename }} < / option > < / select > < / label >
< label v-if = "form.mode==='extend'">Previous clip<select v-model ="form.startClipId" 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 = "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" > {{ s.name | | s.filename }} < / 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" > {{ s.name | | s.filename }} < / option > < / select > < / label >
< label class = "text-sm" > Add still to folder < input type = "file" accept = "image/*" class = "mt-2 block text-xs" @change ="upload" > < / label >
< label class = "text-sm" > Add still to folder < input type = "file" accept = "image/*" class = "mt-2 block text-xs" @change ="upload" > < / label >
< / div >
< / div >
@@ -33,55 +36,63 @@
< section class = "space-y-4" >
< section class = "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 >
< 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 >
< 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 >
< fieldset v-if = "video" class="space-y-2"><legend>Camera</legend><div class="flex flex-wrap gap-2"><button v-for="c in cameraFamilies" :key="c" type="button" class="rounded-full border px-3 py-1 text-xs" :class="form.promptSections.cameraChip?.family===c?'border-amber-300 text-amber-200':'border-white/15'" @click="camera(c)">{{ c==='static'?'Hold / tripod':c }}</button></div><div v-if="form.promptSections.cameraChip" class="flex gap-2"><select v-model="form.promptSections.cameraChip.amplitude" class="rounded bg-zinc-800 p-2"><option v-for="a in ['subtle','moderate','large']" :key="a">{{ a }}</option></select><select v-model="form.promptSections.cameraChip.speed" class="rounded bg-zinc-800 p-2"><option v-for="a in ['slow','medium','fast']" :key="a">{{ a }}</option></select><button @click="delete form.promptSections.cameraChip" > Clear camera < / button > < / div > < / fieldset >
< Studio2PromptControls v-if = "video" v-model="form.promptSections" / >
< template v-for = "section in ['camera','soundscape','dialogue','music','locks']" :key="section"><label v-if="(video || section==='locks') && (section!=='camera' || advanced)" class="block capitalize">{{ section }}<textarea v-model= "form.promptSections[section] " : rows="section==='locks'?2:1 " class="mt-1 w-full rounded-xl bg-zinc-900 p-3" / > < / label > < / template >
< 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 >
< 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 >
< 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 >
< 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 >
< 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 >
< div v-for = "job in j obs.slice(0,4 )" :key="job.id" class="rounded-xl border border-white/10 p-3 text-sm"><div class="flex justify-between"><button @click="focusJob(job)">{{ job.request.mode }} · {{ job.state }}</button><button v-if="!['complete','failed','cancelled'].includes(job.state)" @click="cancel(job)">Cancel</button></div><p v-if="job.error" class="text-red-300" > {{ job.error }} < / p > < / div >
< Studio2JobProgress v-for = "job in folderJ obs.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 >
< details v-if = "advanced"><summary>Compiled prompt</summary><pre class="whitespace-pre-wrap text-xs text-zinc-400" > {{ compiled }} < / pre > < / details >
< / section >
< / section >
< / div >
< / div >
< div v-if = "settingsOpen || detailsOpen" class="fixed inset-0 z-50 bg-black/70" @click.self="settingsOpen=false;detailsOpen=false" >
< 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 >
< 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><label class="mb-4 block">Aspect<select :value="form.settings.width===form.settings.height?'1:1':form.settings.width>form.settings.height?'16:9':'9:16'" @change="size(($event.target as HTMLSelectElement).value)" class="mt-1 w-full rounded bg -zinc-8 00 p-2"><option>16:9</option><option>9:16</option><option>1:1</option></select></label ><label v-for="key in ['width','height',' steps','cfg','seed',...(video?['duration']:[])]" :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 v-if="video"><input v-model="form.settings.turbo" type="checkbox"> Turbo</label><fieldset v-if="advanced" class="mt-5"><legend>LoRAs</legend><div v-for="(l,i) in form.settings.loraStack" :key="i" class="mt-2 space-y-2"><input v-model="l.name" placeholder="Installed LoRA name" class="w-full rounded bg-zinc-800 p-2"><input v-model.number="l.strengthModel" aria-label="LoRA strength" type="number " m in="0" max="2" step="0.05" class="w-24 rounded bg-zinc-800 p-2"><button @click="form.settings.loraStack.splice(i,1)">Remove</button></div ><button class="mt-2 text-amber-200" @click="form.settings.loraStack.push({name:'',strengthModel:1,strengthClip:1}) " > Add LoRA < / button > < / fieldset > < / template >
< 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-4 00">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding, never stretch</p ><label v-for="key in ['steps','cfg','seed',...(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 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 >
< template v-else > < h2 class = "mb-6 text-xl font-semibold" > Output details < / h2 > < Studio2Details :metadata = "selected?.studio2" / > < / template >
< / aside >
< / aside >
< / div >
< / div >
< / main >
< / main >
< / template >
< / template >
< script setup lang = "ts" >
< script setup lang = "ts" >
import { cameraFamilies , compilePrompt , sectionsFrom , restoreInput } from '~/shared/studio2/contracts.mjs'
import { compilePrompt , sectionsFrom } from '~/shared/studio2/contracts.mjs'
import { aspectOptions , resolveSize , bindClip , restoreAsset , extendSelected } from '~/shared/studio2/input.mjs'
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 ) , family = ref < any [ ] > ( [ ] )
const library = ref < any > ( { folders : [ ] , stills : [ ] , clips : [ ] } ) , jobs = ref < any [ ] > ( [ ] ) , selected = ref < any > ( null ) , family = ref < any [ ] > ( [ ] )
const form = reactive < any > ( { mode : 'generate' , engine : 'flux' , folderId : '' , identityStillId : '' , imageAId : '' , imageBId : '' , maskId : '' , startClipId : '' , endStillId : '' , guides : [ ] , promptSections : sectionsFrom ( { } ) , settings : { width : 1344 , height : 768 , steps : 24 , cfg : 4 , seed : 1 , duration : 5 , turbo : true , loraStack : [ ] } } )
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 , width : 1344 , height : 768 , steps : 24 , cfg : 4 , seed : 1 , duration : 5 , turbo : true , loraStack : [ ] } } )
const route = useRoute ( )
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 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 ) )
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 stillUrl = ( id : string ) => ` /api/library/stills/ ${ id } `
function chooseTask ( task : string ) { const was = video . value ; form . mode = task ; 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 } }
const folderJobs = computed ( ( ) => jobs . value . filter ( j => j . request . folderId === form . folderId ) )
function camera ( family : string ) { form . promptSections . cameraChip = { family , amplitude : 'subt le', speed : 'slow' } }
const activeJob = computed ( ( ) => folderJobs . value . find ( j => ! [ 'complete' , 'fai led ', 'cancelled' ] . includes ( j . state ) ) )
function size ( aspect : string ) { const [ w , h ] = aspect === '9:16' ? [ 768 , 1344 ] : aspect === '1:1' ? [ 960 , 960 ] : [ 1344 , 768 ] ; form . settings . width = w ; form . settings . height = h }
const sourceSize = computed ( ( ) => form . startClipId ? clips . value . find ( ( c : any ) => c . id === form . startClipId ) : stills . value . find ( ( s : any ) => s . id === ( form . imageAId || form . identityStillId ) ) )
function restore ( item : any ) { const restored = restoreInput ( item ) ; Object . assign ( form , restored ) ; form . guides = restored . guides || [ ] ; batchText . value = '' }
const resolvedSize = computed ( ( ) => resolveSize ( form . settings . aspect , sourceSize . value , form . settings ) )
function focusJob ( job : any ) { family . value = job . outputs || [ ] ; selected . value = family . value . at ( - 1 ) || null }
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 } }
async function queue ( ) { submitting . value = true ; error . value = '' ; try { await $fetch ( '/api/studio-2/jobs' , { method : 'POST' , body : { ... form , batch : batchText . value . split ( '\n' ) . map ( s => s . trim ( ) ) . filter ( Boolean ) } } ) ; await refresh ( ) } catch ( e : any ) { error . value = e . data ? . statusMessage || e . message } finally { submitting . value = false } }
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 ; if ( item ? . kind === 'clip' && form . startClipId !== item . id ) { Object . assign ( form , bindClip ( form , item ) ) ; form . mode = 'extend' } }
function restore ( item : any ) { Object . assign ( form , restoreAsset ( form , item ) ) ; form . guides = form . guides || [ ] ; batchText . value = '' }
function focusJob ( job : any ) { family . value = job . outputs || [ ] ; selectOutput ( family . value . at ( - 1 ) || null ) }
async function loadLoras ( ) { loraNames . value = await $fetch ( '/api/loras' ) . catch ( ( ) => ( { image : [ ] , video : [ ] } ) ) }
async function queue ( ) { submitting . value = true ; error . value = '' ; try { await $fetch ( '/api/studio-2/jobs' , { method : 'POST' , body : { ... form , settings : { ... form . settings , ... resolvedSize . value } , batch : batchText . value . split ( '\n' ) . map ( s => s . trim ( ) ) . filter ( Boolean ) } } ) ; 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 cancel ( job : any ) { await $fetch ( '/api/studio-2/cancel' , { method : 'POST' , body : { id : job . id } } ) ; await refresh ( ) }
async function upload ( event : Event ) { const file = ( event . target as HTMLInputElement ) . files ? . [ 0 ] ; if ( ! file ) return ; const body = new FormData ( ) ; body . append ( 'image' , file ) ; body . append ( 'folderId' , form . folderId ) ; try { library . value = await $fetch ( '/api/library/stills' , { method : 'POST' , body } ) } catch ( e : any ) { error . value = e . data ? . statusMessage || e . message } }
async function upload ( event : Event ) { const file = ( event . target as HTMLInputElement ) . files ? . [ 0 ] ; if ( ! file ) return ; const body = new FormData ( ) ; body . append ( 'image' , file ) ; body . append ( 'folderId' , form . folderId ) ; try { library . value = await $fetch ( '/api/library/stills' , { method : 'POST' , body } ) } catch ( e : any ) { error . value = e . data ? . statusMessage || e . message } }
async function refresh ( ) {
async function refresh ( ) {
jobs . value = await $fetch < any [ ] > ( '/api/studio-2/jobs' )
jobs . value = await $fetch < any [ ] > ( '/api/studio-2/jobs' )
const latest = jobs . value . find ( j => j . request . folderId === form . folderId && j . outputs ? . length )
const latest = jobs . value . find ( j => j . request . folderId === form . folderId && j . outputs ? . length )
if ( latest && ! selected . value ) { focusJob ( latest ) ; return }
if ( latest && ! selected . value ) { library . value = await $fetch ( '/api/library?light=1' ) ; focusJob ( latest ) ; return }
const current = jobs . value . find ( j => j . outputs ? . some ( ( o : any ) => family . value . some ( f => f . id === o . id ) ) )
const current = jobs . value . find ( j => j . outputs ? . some ( ( o : any ) => family . value . some ( f => f . id === o . id ) ) )
if ( current && current . outputs . some ( ( o : any ) => ! family . value . some ( f => f . id === o . id ) ) ) {
if ( current && current . outputs . some ( ( o : any ) => ! family . value . some ( f => f . id === o . id ) ) ) {
const following = selected . value ? . id === family . value . at ( - 1 ) ? . id
const following = selected . value ? . id === family . value . at ( - 1 ) ? . id
family . value = current . outputs
family . value = current . outputs
if ( following ) selected . value = family . value . at ( - 1 )
if ( following ) selectOutput ( family . value . at ( - 1 ) )
library . value = await $fetch ( '/api/library?light=1' )
library . value = await $fetch ( '/api/library?light=1' )
}
}
}
}
watch ( ( ) => form . folderId , id => { if ( import . meta . client ) form . identityStillId = localStorage . getItem ( ` studio2-hero: ${ id } ` ) || '' } , { flush : 'sync' } )
watch ( ( ) => form . folderId , 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 ) } )
watch ( ( ) => form . identityStillId , id => { if ( import . meta . client && form . folderId ) localStorage . setItem ( ` studio2-hero: ${ form . folderId } ` , id ) } )
let timer : ReturnType < typeof setTimeout > , disposed = false
watch ( ( ) => form . hideInput , value => { if ( import . meta . client ) localStorage . setItem ( 'studio2-hide-input' , String ( value ) ) } )
onMounted ( async ( ) => { 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' } ; family . value = [ selected . value ] ; if ( route . query . use === '1' ) restore ( asset ) } ; const poll = async ( ) => { try { await refresh ( ) } catch ( e : any ) { error . value = e . data ? . statusMessage || e . message } if ( ! disposed ) timer = setTimeout ( poll , 3000 ) } ; void poll ( ) } )
let timer : ReturnType < typeof setTimeout > , clock : ReturnType < typeof setInterval > , disposed = false
onBeforeUnmount ( ( ) => { disposed = true ; clearTimeout ( timer ) } )
onMounted ( async ( ) => { 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' } ; family . value = [ selected . value ] ; 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 >
< / script >