@@ -4,73 +4,79 @@
< 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-8 00 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" >
< label class = "text-sm" > < input v-model = "form.lockFace" type="checkbox"> Lock face <span class="block text-xs text -zinc-5 00">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 >
< 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.hideIn put" type="checkbox"> Hide input stills < / label > < / div >
< 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="hideOut put" 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" >
< video v-if = "selected?.kind==='clip'" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full object-contain" / >
< 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" / >
< 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.endStill Id" 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 >
< 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 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')" / >
< Studio2AssetSlot : label = "form.mode==='extend'?'Previous clip':'Start still'" :asset = "sourceSlot" :hidden = "inputHidden" 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="inputHidden" @pick="picker='imageBId'" / >
< Studio2AssetSlot v-if = "form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="inputHidden" @pick="picker='maskId'" / >
< Studio2AssetSlot v-if = "video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="inputHidden" @pick="picker='endStillId'" / >
< / div > < label v-if = "form.mode==='refine'" class="mt-3 block text-sm">Mask still (white = change). On-canvas mask comes later. <select v-model="form.mask Id" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Choose mask still </option><option v-for="a in stills" :key="a .id" :value="a .id">{{ assetLabel(a) }}</option></select></label><p v-if="uploading || uploadMessage" role="status" class="mt-2 text-sm text-amber-200" > {{ uploading ? ' Uploading image … ' : uploadMessage }} < / p > < / section >
< 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"><Studio2AssetSlot label="Guide" :asset="stills.find((a:any)=>a.id===g.stillId)" :hidden="inputHidden" @pick="picker='guide:'+i" /><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 v-if = "video" 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 ac tion prompt per line. Hero stays attached to every output. " / > < / details >
< label class = "block" > Prompt < textarea v-model = "form.promptSections.action" rows="4" class="mt-1 w-full rounded-xl bg-zinc-900 p-3" placeholder="Describe the image or the shot. " / > < / label >
< Studio2PromptControls v-if = "video" v-model="form.promptSections" /><Studio2StyleChips v-else v-model="form.imageStyles" / >
< label v-if = "video" class="block">Extra constraints (optional) — do not repeat the Prompt. Example: keep the black glasses, no extra people. <textarea v-model="form.promptSections.locks" rows="2" class="mt-1 w-full rounded-xl bg-zinc-900 p-3" / > < / label >
< details v-if = "video || form.mode==='edit'" class=" rounded-xl border border-white/10 p-3"><summary>{{ video?'Queue more shots':'Queue extra edit passes' }}</summary><p class="mt-2 text-xs text-zinc-400">Each blank-line-separated block is another job. Hero and settings stay. Press Enter twice between shots. A wrapped sentence is still one shot.{{ video?'':' Each extra pass edits the previous result with Klein.' }}</p><textarea v-model= "batchText" rows="5" class="mt-2 w-full rounded-xl bg-zinc-900 p-3" placeholder="First addi tional prompt Next additional prompt " / > < / 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 >
< 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 >
< 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-x s text-zinc-400" > {{ compiled }} < / pre > < / details >
< 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 >
< / 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-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" filename-subtitle :aliases="loraNames.aliases || library.loraAliases" :triggers="loraNames.triggers || library.loraTriggers" missing-label="No cached LoRAs yet — Refresh to check Comfy" @insert-trigger="form.promptSections.action += ' '+$event" /><button class="mt-2 text-sm text-amber-200" @click="loadLoras(true)">Refresh LoRAs</button><details class="mt-6"><summary>Compiled prompt</summary><pre class="whitespace-pre-wrap text-xs text-zinc-400">{{ compiled }}</pre><p v-if="!video" class="mt-3 text-xs text-zinc-400" > Negative : {{ stylePrompt ( form.imageStyles , true ) | | ' None ' }} < / p > < / details > < / 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-2xl border border-white/15 bg-zinc-950 p-5" > < div class = "mb-4 flex justify-between" > < h2 > Choose { { picker === 'identityStillId' ? 'hero' : 'source' } } < / h2 > < button @click ="picker=''" > Close < / button > < / div > < button class = "mb-4 text-sm text-zinc-400" @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" class="rounded-xl border border-white/15 p-2 text-left" @click="pickAsset(a)"><span v-if="inputHidden" class="flex h-24 items-center text-xs text-zinc-400">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="Source option"><span class="mt-2 block truncate text-xs" > {{ assetLabel ( a ) }} < / span > < / button > < / div > < / section >
< / div >
< / main >
< / template >
< script setup lang = "ts" >
import { compilePrompt , sectionsFrom } from '~/shared/studio2/contracts.mjs'
import { aspectOptions , resolveSize , bindClip , restoreAsset , extendSelected , changeSource } from '~/shared/studio2/input.mjs'
import { folderAssets , newestOutput , assetLabel } from '~/shared/studio2/viewer.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 { applyUploadedStill } from '~/shared/studio2/upload.mjs'
const xaigen = useRuntimeConfig ( ) . public . studio === 'xaigen' , hideOutput = ref ( false ) , picker = ref ( '' )
const inputHidden = computed ( ( ) => xaigen && form . hideInput )
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 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 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 ( )
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' , 'video' , 'extend' ] , 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 benchJobs = computed ( ( ) => activeJobs ( folderJobs . value ) )
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 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 ) )
@@ -80,12 +86,15 @@ const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.valu
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 , useRuntimeConfig ( ) . public . studio === 'xaigen' ) ) ; 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 , useRuntimeConfig ( ) . public . studio === 'xaigen' ) ) }
function selectOutput ( item : any ) { selected . value = item ; if ( form . mode === 'extend' && item ? . kind === 'clip' ) Object . assign ( form , extendSelected ( form , item , useRuntimeConfig ( ) . public . studio === 'xaigen' ) ) }
function selectOutput ( item : any ) { selected . value = item }
function sourceChanged ( ) { Object . assign ( form , changeSource ( form ) ) }
function restore ( item : any ) { Object . assign ( form , restoreAsset ( form , item ) ) ; form . guides = form . guides || [ ] ; batchText . value = '' }
function restore ( item : any ) { Object . assign ( form , restoreAll ( 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 , promptSections : video . value ? form . promptSections : { action : form . promptSections . action } , 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 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 { await $fetch ( '/api/library/' + ( selected . value . kind === 'clip' ? 'clips/' : 'stills/' ) + 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 . 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 : inputHidden . value , hideOutput : xaigen && hideOutput . value , 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
@@ -97,18 +106,19 @@ 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/library?light=1 ' ) ] )
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 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 ) )
const existingFresh = family . value . find ( a => a . id === fresh ? . id )
selected . value = existingFresh || reconcileSelection ( selected . value , family . value )
}
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 ( 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 = 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 ( ) } )
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 ( ) } )
onBeforeUnmount ( ( ) => { disposed = true ; clearTimeout ( timer ) ; clearInterval ( clock ) } )
< / script >