Compare commits

...
3 Commits
14 changed files with 299 additions and 57 deletions
+32 -21
View File
@@ -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 jobs.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 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> <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-800 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" min="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-400">{{ 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:'subtle',speed:'slow'}} const activeJob=computed(()=>folderJobs.value.find(j=>!['complete','failed','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>
+3 -1
View File
@@ -11,7 +11,9 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=
{label:'Seed',value:s.seed},{label:'Steps',value:s.steps},{label:'CFG',value:s.cfg}, {label:'Seed',value:s.seed},{label:'Steps',value:s.steps},{label:'CFG',value:s.cfg},
{label:'Size',value:s.width&&s.height?`${s.width} × ${s.height}`:null},{label:'Duration',value:s.duration!=null?`${s.duration}s`:null}, {label:'Size',value:s.width&&s.height?`${s.width} × ${s.height}`:null},{label:'Duration',value:s.duration!=null?`${s.duration}s`:null},
{label:'LoRAs',value:s.loraStack?.length?s.loraStack.map((l:any)=>`${l.name} · ${l.strengthModel}`).join('\n'):'None'}, {label:'LoRAs',value:s.loraStack?.length?s.loraStack.map((l:any)=>`${l.name} · ${l.strengthModel}`).join('\n'):'None'},
{label:'Identity still',value:m.identityStillId||'None'},{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'}, {label:'Identity still',value:m.identityStillId||'None'},{label:'Face lock',value:m.lockFace!==false?'On':'Off'},{label:'Outfit lock',value:m.lockOutfit!==false?'On':'Off'},
{label:'Aspect',value:s.aspect||'auto'},{label:'FPS',value:s.fps||24},{label:'Hide input',value:m.hideInput?'On':'Off'},
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'}, {label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
{label:'Compiled prompt',value:m.compiledPrompt},{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)}, {label:'Compiled prompt',value:m.compiledPrompt},{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
{label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult} {label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult}
+15
View File
@@ -0,0 +1,15 @@
<template>
<div class="space-y-2 rounded-xl border border-white/10 p-3 text-sm" aria-live="polite">
<div class="flex justify-between gap-3"><button class="text-left" @click="$emit('select')"><strong>{{ job.request.promptSections.action.slice(0,70) }}</strong><span class="block capitalize text-amber-200">{{ job.state==='waiting'?'queued':job.state }} · Shot {{ shot }}/{{ total }}</span></button><button v-if="!terminal" @click="$emit('cancel')">Cancel</button></div>
<p>{{ elapsed }} elapsed · {{ job.request.settings.duration }}s target<span v-if="job.state==='rendering' && job.progress"> · Step {{ job.progress.step }}/{{ job.progress.maxStep }} ({{ job.progress.percent }}%)</span></p>
<progress v-if="job.state==='rendering'" class="h-2 w-full accent-amber-300" :value="job.progress?.percent" max="100" />
<p v-if="job.error" role="alert" class="text-red-300">{{ job.error }}</p>
</div>
</template>
<script setup lang="ts">
const props=defineProps<{job:any;now:number}>();defineEmits(['select','cancel'])
const terminal=computed(()=>['complete','failed','cancelled'].includes(props.job.state))
const total=computed(()=>props.job.prompts?.length || (props.job.request.batch?.length || 0)+1)
const shot=computed(()=>Math.min(total.value,(props.job.index || 0)+1))
const elapsed=computed(()=>{const seconds=Math.max(0,Math.floor(((terminal.value?props.job.updatedAt:props.now)-(props.job.startedAt||props.job.queuedAt))/1000));return `${Math.floor(seconds/60)}m ${seconds%60}s`})
</script>
+22
View File
@@ -0,0 +1,22 @@
<template>
<div class="space-y-4">
<fieldset class="space-y-3"><legend class="font-semibold">Camera beats · one continuous take</legend>
<div class="flex flex-wrap gap-2"><button v-for="(beat,i) in model.cameraBeats" :key="i" type="button" class="rounded-lg border px-3 py-2" :class="active===i?'border-amber-300 text-amber-200':'border-white/20'" @click="active=i">{{ i+1 }} · {{ beat.moves.join(' + ') }} · {{ beat.durationSec == null ? 'to end' : `${beat.durationSec}s` }}</button></div>
<div v-if="current" class="space-y-3 rounded-xl border border-white/15 p-3">
<div class="flex flex-wrap gap-2"><button v-for="move in cameraMoves" :key="move" type="button" :aria-pressed="current.moves.includes(move)" class="rounded-full border px-3 py-1 text-sm" :class="current.moves.includes(move)?'border-amber-300 bg-amber-300/10 text-amber-200':'border-white/15'" @click="model.cameraBeats[active]=toggleMove(current,move)">{{ move.replaceAll('-',' ') }}</button></div>
<div class="flex flex-wrap gap-3"><label>Amplitude<select v-model="current.amplitude" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['subtle','moderate','large']" :key="v">{{ v }}</option></select></label><label>Speed<select v-model="current.speed" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['slow','medium','fast']" :key="v">{{ v }}</option></select></label><label>Duration<select v-model="current.durationSec" class="block rounded bg-zinc-800 p-2"><option :value="null">To end</option><option v-for="n in 30" :key="n" :value="n/2">{{ n/2 }}s</option></select></label><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
</div>
<div class="flex gap-4 text-sm text-amber-200"><button @click="addBeat">+ Camera beat</button><button @click="model.cameraBeats=[];model.camera='';active=0">Clear camera</button></div>
</fieldset>
<fieldset class="space-y-2"><legend class="font-semibold">Soundscape</legend><div class="flex flex-wrap gap-2"><button v-for="sound in soundscapeOptions" :key="sound" :aria-pressed="model.soundscapeChips.includes(sound)" class="rounded-full border px-3 py-1 text-sm" :class="model.soundscapeChips.includes(sound)?'border-amber-300 text-amber-200':'border-white/15'" @click="toggleSound(sound)">{{ sound }}</button></div><label class="block text-sm">Dynamics <select v-model="model.soundscapeLevel" class="rounded bg-zinc-800 p-2"><option>normal</option><option>crush</option></select></label></fieldset>
<fieldset class="space-y-2"><legend class="font-semibold">Dialogue</legend><div class="flex flex-wrap gap-2"><button v-for="mode in ['none','spoken to camera','spoken off-screen','whisper']" :key="mode" :aria-pressed="model.dialogue.mode===mode" class="rounded-full border px-3 py-1 text-sm" :class="model.dialogue.mode===mode?'border-amber-300 text-amber-200':'border-white/15'" @click="model.dialogue.mode=mode">{{ mode }}</button></div><template v-if="model.dialogue.mode!=='none'"><div class="flex gap-3"><label>Language<input v-model="model.dialogue.language" class="block w-full rounded bg-zinc-800 p-2"></label><label>Speaker<input v-model="model.dialogue.speaker" class="block w-20 rounded bg-zinc-800 p-2"></label></div><label class="block">Line<textarea v-model="model.dialogue.line" class="mt-1 w-full rounded-xl bg-zinc-900 p-3" /></label></template></fieldset>
<fieldset><legend class="mb-2 font-semibold">Music</legend><div class="flex gap-2"><button v-for="v in ['N/A','none','low underscore']" :key="v" :aria-pressed="model.musicChip===v" class="rounded-full border px-3 py-1 text-sm" :class="model.musicChip===v?'border-amber-300 text-amber-200':'border-white/15'" @click="model.musicChip=v;model.music=''">{{ v }}</button></div></fieldset>
</div>
</template>
<script setup lang="ts">
import {cameraMoves,soundscapeOptions,normalizeBeat,toggleMove} from '~/shared/studio2/prompt.mjs'
const model=defineModel<any>({required:true}),active=ref(0)
const current=computed(()=>model.value.cameraBeats[active.value])
function addBeat(){const last=model.value.cameraBeats.at(-1);if(last?.durationSec==null&&last)last.durationSec=2;model.value.cameraBeats.push(normalizeBeat());active.value=model.value.cameraBeats.length-1}
function toggleSound(sound:string){const chips=model.value.soundscapeChips;model.value.soundscapeChips=chips.includes(sound)?chips.filter((s:string)=>s!==sound):[...chips,sound]}
</script>
+13
View File
@@ -0,0 +1,13 @@
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { runFfmpeg } from '../ffmpeg'
export async function fitStill(data: Buffer, width: number, height: number) {
const dir=mkdtempSync(join(tmpdir(),'studio2-fit-'))
try {
const input=join(dir,'source'),output=join(dir,'fitted.png')
writeFileSync(input,data)
await runFfmpeg(['-y','-i',input,'-vf',`scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`,'-frames:v','1',output])
return readFileSync(output)
} finally { rmSync(dir,{recursive:true,force:true}) }
}
+28
View File
@@ -0,0 +1,28 @@
import { comfyWsUrl } from '../comfy'
import { saveRecord } from './store'
/** Listen only to this client's prompt; history polling remains the completion authority. */
export function watchProgress(record: any, job: any) {
let ws: WebSocket | undefined, timer: ReturnType<typeof setTimeout>, stopped = false
const connect = () => {
if (stopped) return
try {
ws = new WebSocket(comfyWsUrl(job.clientId))
ws.addEventListener('message', event => {
if (typeof event.data !== 'string') return
try {
const {type,data} = JSON.parse(event.data)
if (!record.promptId || (data?.prompt_id && data.prompt_id !== record.promptId)) return
if (type === 'progress' && Number(data.max) > 0) {
record.progress = {step:Number(data.value),maxStep:Number(data.max),percent:Math.round(Number(data.value)/Number(data.max)*100),updatedAt:Date.now()}
saveRecord(record)
}
} catch { /* Binary previews and malformed events cannot interrupt a job. */ }
})
ws.addEventListener('error', () => { ws?.close() })
ws.addEventListener('close', () => { if (!stopped) timer=setTimeout(connect,3000) })
} catch { if (!stopped) timer=setTimeout(connect,3000) }
}
connect()
return () => { stopped=true;clearTimeout(timer);ws?.close() }
}
+20 -10
View File
@@ -1,3 +1,6 @@
import { watchProgress } from './progress';
import { fitStill } from './media';
import { resolveSize } from '~/shared/studio2/input.mjs';
import { readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'; import { readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import template from '../../assets/studio2_minimax_native.json'; import template from '../../assets/studio2_minimax_native.json';
@@ -25,6 +28,7 @@ type DiskFile = {
function update(r: any, state: string) { r.state = state; r.updatedAt = Date.now(); saveRecord(r); } function update(r: any, state: string) { r.state = state; r.updatedAt = Date.now(); saveRecord(r); }
async function upload(r: any, name: string, data: Buffer): Promise<string> { async function upload(r: any, name: string, data: Buffer): Promise<string> {
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`; const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
if (name !== 'hero') data = await fitStill(data, r.request.settings.width, r.request.settings.height);
const body = new FormData(); const body = new FormData();
body.append('image', new Blob([new Uint8Array(data)]), name + '.png'); body.append('image', new Blob([new Uint8Array(data)]), name + '.png');
body.append('subfolder', prefix); body.append('subfolder', prefix);
@@ -61,7 +65,10 @@ async function purge(r: any) {
async function prepareGraph(r: any) { async function prepareGraph(r: any) {
const q = r.request, s = q.settings, prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`; const q = r.request, s = q.settings, prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
const load = async (id: string, label: string) => id ? upload(r, label, readFileSync(stillPath(r.owner, getStill(r.owner, id).id))) : ''; const load = async (id: string, label: string) => id ? upload(r, label, readFileSync(stillPath(r.owner, getStill(r.owner, id).id))) : '';
const hero = await load(q.identityStillId, 'hero'), a = await load(q.imageAId, 'source'), b = await load(q.imageBId, 'compose'), mask = await load(q.maskId, 'mask'); const source = q.startClipId ? getClip(r.owner,q.startClipId) : (q.imageAId || q.identityStillId) ? getStill(r.owner,q.imageAId || q.identityStillId) : null;
Object.assign(s,resolveSize(s.aspect,source,s));
saveRecord(r);
const hero = q.lockFace === false && q.lockOutfit === false ? '' : await load(q.identityStillId, 'hero'), a = await load(q.imageAId, 'source'), b = await load(q.imageBId, 'compose'), mask = await load(q.maskId, 'mask');
let graph: any; let graph: any;
if (['video', 'extend'].includes(q.mode)) { if (['video', 'extend'].includes(q.mode)) {
if (q.engine !== 'minimax') if (q.engine !== 'minimax')
@@ -101,6 +108,7 @@ async function prepareGraph(r: any) {
return graph; return graph;
} }
async function run(r: any, job: Job) { async function run(r: any, job: Job) {
const stopProgress = watchProgress(r,job);
try { try {
const prompts = r.prompts || [r.request.promptSections, ...r.request.batch]; const prompts = r.prompts || [r.request.promptSections, ...r.request.batch];
r.prompts = prompts; r.prompts = prompts;
@@ -115,8 +123,9 @@ async function run(r: any, job: Job) {
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
} }
r.files = []; r.files = [];
r.progress = null;
r.request.promptSections = prompts[r.index]; r.request.promptSections = prompts[r.index];
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode)); r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
update(r, 'waking'); update(r, 'waking');
await ensureComfyReady(message => emitJob(job, { type: 'status', message })); await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
if (job.status === 'cancelled') if (job.status === 'cancelled')
@@ -188,12 +197,7 @@ async function run(r: any, job: Job) {
const tmp = join(studio2Root(), r.id); const tmp = join(studio2Root(), r.id);
mkdirSync(tmp, { recursive: true }); mkdirSync(tmp, { recursive: true });
const assembled = q.startClipId ? await stitchExtension({ part1Path: clipVideoPath(r.owner, q.startClipId), part2: data, tmpDir: tmp, sourcePaths: sourceSegments }) : data; const assembled = q.startClipId ? await stitchExtension({ part1Path: clipVideoPath(r.owner, q.startClipId), part2: data, tmpDir: tmp, sourcePaths: sourceSegments }) : data;
asset = await saveClip({ ownerKey: r.owner, folderId: q.folderId, prompt: q.compiledPrompt, ...s, aspect: `${s.width}:${s.height}`, hideThumbnail: false, video: assembled, originalSegment: data, sourceSegments, fps: 24, sound: true, familyId: r.familyId, parentClipId: q.startClipId || undefined, chainIndex: r.index, comfyFilename: file.filename }); asset = await saveClip({ ownerKey: r.owner, folderId: q.folderId, prompt: q.compiledPrompt, ...s, aspect: s.aspect || 'auto', hideThumbnail: false, video: assembled, originalSegment: data, sourceSegments, fps: s.fps || 24, sound: true, familyId: r.familyId, parentClipId: q.startClipId || undefined, chainIndex: r.index, comfyFilename: file.filename });
const anchor = history[r.promptId].outputs?.anchor_save?.images?.[0];
if (anchor) {
r.files.push(anchor);
await persistClipAnchorFrame({ ownerKey: r.owner, clipId: asset.id, fromBuffer: await downloadComfyImage(anchor) });
}
} }
else if (!asset) { else if (!asset) {
const data = await downloadComfyImage(file), size = imageDimensions(data) || { width: s.width, height: s.height }; const data = await downloadComfyImage(file), size = imageDimensions(data) || { width: s.width, height: s.height };
@@ -207,11 +211,16 @@ async function run(r: any, job: Job) {
r.wallTime = (r.savedAt - r.startedAt) / 1000; r.wallTime = (r.savedAt - r.startedAt) / 1000;
const metadata = { ...structuredClone(q), id: r.id, kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host' }; const metadata = { ...structuredClone(q), id: r.id, kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host' };
await attachStudio2Metadata(r.owner, asset.id, metadata); await attachStudio2Metadata(r.owner, asset.id, metadata);
if (!r.outputs.some((o:any)=>o.id===asset.id)) r.outputs.push({id:asset.id,kind:video?'clip':'still',studio2:metadata});
saveRecord(r);
const anchor = video && history[r.promptId].outputs?.anchor_save?.images?.[0];
if (anchor) {
if (!r.files.some((f: DiskFile)=>f.filename===anchor.filename && f.subfolder===anchor.subfolder)) r.files.push(anchor);
await persistClipAnchorFrame({ownerKey:r.owner,clipId:asset.id,fromBuffer:await downloadComfyImage(anchor)});
}
await purge(r); await purge(r);
metadata.purgeResult = r.purgeResult; metadata.purgeResult = r.purgeResult;
await attachStudio2Metadata(r.owner, asset.id, metadata); await attachStudio2Metadata(r.owner, asset.id, metadata);
if (!r.outputs.some((o: any) => o.id === asset.id))
r.outputs.push({ id: asset.id, kind: video ? 'clip' : 'still', studio2: metadata });
saveRecord(r); saveRecord(r);
job.saving = false; job.saving = false;
if (video) { if (video) {
@@ -241,6 +250,7 @@ async function run(r: any, job: Job) {
emitJob(job, { type: 'error', message: e.message }); emitJob(job, { type: 'error', message: e.message });
} }
finally { finally {
stopProgress();
job.saving = false; job.saving = false;
await onLiveVideoSettled(job); await onLiveVideoSettled(job);
} }
+23 -19
View File
@@ -1,21 +1,19 @@
export const identityLock = '<Picture 1> is identity. Face, hair, glasses, outfit fully preserved. Start frame is composition only. Do not copy facial drift from the start frame.' import { sectionsFrom, compileSections } from './prompt.mjs'
export { sectionsFrom } from './prompt.mjs'
export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right'] export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right']
export function sectionsFrom(value = {}) { export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.'
value = value || {} export function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
if (typeof value === 'string') value = { action: value } if (lockFace && lockOutfit) return identityLock
const sections = Object.fromEntries(['action','camera','soundscape','dialogue','music','locks'].map(key => [key, String(value[key] || '')])) if (lockFace) return '<Picture 1> / hero is identity for face, hair, glasses, and body only — not wardrobe. The start frame is composition, pose, place, and clothing. Do not restore an earlier outfit. Do not reset clothes, straps, or print to the hero still.'
const chip = value.cameraChip if (lockOutfit) return '<Picture 1> / hero is wardrobe only. Preserve clothes from the hero. Face, hair, glasses, body, pose, place, and camera follow the start frame.'
if (chip && cameraFamilies.includes(chip.family)) sections.cameraChip = { family: chip.family, amplitude: ['subtle','moderate','large'].includes(chip.amplitude) ? chip.amplitude : 'subtle', speed: ['slow','medium','fast'].includes(chip.speed) ? chip.speed : 'slow' } return 'The start frame is the only appearance reference for person and clothing, as well as pose, place, and camera. Do not use the hero as an appearance reference.'
return sections
} }
export function compilePrompt(value, video = false) { export function compilePrompt(value, video = false, locks = {}) {
const s = sectionsFrom(value), c = s.cameraChip return [compileSections(value), video ? appearanceLock(locks) : ''].filter(Boolean).join('\n\n')
const camera = c ? (c.family === 'static' ? 'Static shot. Camera locked on a tripod.' : `Camera ${c.family}, ${c.amplitude} movement, ${c.speed} speed.`) : s.camera
return [s.action, camera && `Camera: ${camera}`, s.soundscape && `Soundscape: ${s.soundscape}`, s.dialogue && `Dialogue: (S1) <d>[English]${s.dialogue}</d>`, s.music && `Music: ${s.music}`, s.locks && `Locks: ${s.locks}`, video ? identityLock : ''].filter(Boolean).join('\n\n')
} }
export function restoreInput(item) { export function restoreInput(item) {
const saved = item.studio2 || item const saved = structuredClone(item.studio2 || item)
return { ...saved, promptSections: sectionsFrom(saved.promptSections || { action: item.prompt || saved.compiledPrompt || '' }) } return { ...saved, lockFace:saved.lockFace !== false, lockOutfit:saved.lockOutfit !== false, hideInput:!!saved.hideInput, settings:structuredClone(saved.settings || {}), promptSections: sectionsFrom(saved.promptSections || { action: item.prompt || saved.compiledPrompt || '' }) }
} }
export function validateRequest(raw, xaigen = false) { export function validateRequest(raw, xaigen = false) {
raw = raw || {} raw = raw || {}
@@ -29,7 +27,7 @@ export function validateRequest(raw, xaigen = false) {
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.') if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.') if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
if (!raw.folderId) fail('Choose a folder.') if (!raw.folderId) fail('Choose a folder.')
if ((video || ['edit','compose','iterate'].includes(mode)) && !raw.identityStillId) fail('Choose a sticky hero still.') if ((video || ['edit','compose','iterate'].includes(mode)) && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId) fail('Choose a sticky hero still.')
if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.') if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.') if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
if (['edit','compose','refine'].includes(mode) && !raw.imageAId) fail('Choose the source still.') if (['edit','compose','refine'].includes(mode) && !raw.imageAId) fail('Choose the source still.')
@@ -39,11 +37,17 @@ export function validateRequest(raw, xaigen = false) {
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n } const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
for (const key of ['steps','seed']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`) for (const key of ['steps','seed']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.') for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
const frames=Math.ceil((number('duration',5,1,15)*24-5)/17)*17+5 const duration=number('duration',5,1,15)
return { mode, engine, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '', if (video) {
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
if (promptSections.cameraBeats.reduce((sum,b)=>sum+(b.durationSec || 0),0)>duration) fail('Camera beats exceed the clip duration.')
if (raw.lockFace === false && raw.lockOutfit === false && !raw.imageAId && !raw.startClipId) fail('Choose a start still or previous clip when both identity locks are off.')
}
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
return { mode, engine, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''}, startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
promptSections, compiledPrompt:compilePrompt(promptSections,video), promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
settings: { width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seed:number('seed',Math.floor(Math.random()*2147483647),0,2147483647),duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] }, settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seed:number('seed',Math.floor(Math.random()*2147483647),0,2147483647),duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }), guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return next}) } batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return next}) }
} }
+6 -4
View File
@@ -1,9 +1,10 @@
export function nativeVideoGraph(template, request, files, prefix) { export function nativeVideoGraph(template, request, files, prefix) {
if (!files.hero) throw new Error('Identity still is required; never substitute the start frame.') const appearance = request.lockFace !== false || request.lockOutfit !== false
if (appearance && !files.hero) throw new Error('Identity still is required; never substitute the start frame.')
const g=structuredClone(template), s=request.settings const g=structuredClone(template), s=request.settings
g.hero.inputs.image=files.hero if (appearance) { g.hero.inputs.image=files.hero; g['155'].inputs['ref_images.ref_image_0']=['hero',0] }
Object.assign(g['155'].inputs,{prompt:request.compiledPrompt,width:s.width,height:s.height,length:Math.ceil((s.duration*24-5)/17)*17+5,ref_image_size:'max'}) else { delete g.hero; delete g['155'].inputs['ref_images.ref_image_0'] }
g['155'].inputs['ref_images.ref_image_0']=['hero',0] Object.assign(g['155'].inputs,{prompt:request.compiledPrompt,width:s.width,height:s.height,length:Math.ceil((s.duration*(s.fps || 24)-5)/17)*17+5,ref_image_size:'max'})
let conditioning=['155',0] let conditioning=['155',0]
for (const [name,image,frame] of [['start',files.start,0],['end',files.end,-1],...(files.guides||[]).map((f,i)=>[`guide${i}`,f.image,f.frame])]) { for (const [name,image,frame] of [['start',files.start,0],['end',files.end,-1],...(files.guides||[]).map((f,i)=>[`guide${i}`,f.image,f.frame])]) {
if (!image) continue if (!image) continue
@@ -13,6 +14,7 @@ export function nativeVideoGraph(template, request, files, prefix) {
} }
g.guidance.inputs={conditioning,guidance:s.cfg} g.guidance.inputs={conditioning,guidance:s.cfg}
g['171'].inputs.noise_seed=s.seed g['171'].inputs.noise_seed=s.seed
g['172'].inputs.fps=s.fps || 24
g['179'].inputs.value=s.steps;g['180'].inputs.value=s.steps;g['181'].inputs.value=s.turbo g['179'].inputs.value=s.steps;g['180'].inputs.value=s.steps;g['181'].inputs.value=s.turbo
g['92'].inputs.filename_prefix=prefix+'/video' g['92'].inputs.filename_prefix=prefix+'/video'
g.anchor_frame={class_type:'ImageFromBatch',inputs:{image:['164',0],batch_index:g['155'].inputs.length-1,length:1}} g.anchor_frame={class_type:'ImageFromBatch',inputs:{image:['164',0],batch_index:g['155'].inputs.length-1,length:1}}
+31
View File
@@ -0,0 +1,31 @@
import { restoreInput } from './contracts.mjs'
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) {
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
const factor = Math.min(1344/Math.max(source.width,source.height),768/Math.min(source.width,source.height))
return {width:Math.max(256,Math.round(source.width*factor/32)*32),height:Math.max(256,Math.round(source.height*factor/32)*32)}
}
export function bindClip(form, clip) {
if (!clip?.id) return form
const saved = clip.studio2 || {}, settings = saved.settings || clip.settings || clip
const next = {...form, startClipId:clip.id, settings:{...form.settings}}
for (const key of ['duration','aspect','width','height','fps','loraStack','turbo','steps','cfg']) {
if (settings[key] != null) next.settings[key] = structuredClone(settings[key])
}
next.engine = saved.engine || clip.engine || 'minimax'
if (saved.identityStillId) next.identityStillId = saved.identityStillId
// Missing legacy metadata must not reset a user's current choice.
for (const key of ['lockFace','lockOutfit']) if (typeof saved[key] === 'boolean') next[key] = saved[key]
return next
}
export function restoreAsset(form, item) {
const restored = restoreInput(item)
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
return item.kind === 'clip' || item.studio2?.kind === 'video' ? {...bindClip(next,item),mode:'extend'} : next
}
export function extendSelected(form, clip) {
// Switching task on the already bound clip must preserve the user's overrides.
return {...(clip?.id && form.startClipId !== clip.id ? bindClip(form,clip) : form),mode:'extend'}
}
+40
View File
@@ -0,0 +1,40 @@
export const cameraMoves = ['hold','dolly-in','dolly-out','pan-left','pan-right','tilt-up','tilt-down','zoom-in','zoom-out','orbit-left','orbit-right']
export const soundscapeOptions = ['quiet room','distant birds','fabric rustle','footsteps','soft cloth','city hush','rain on glass','room tone only','no extra SFX']
export function normalizeBeat(beat = {}) {
let moves = [...new Set((beat.moves || []).filter(m => cameraMoves.includes(m)))]
if (moves.includes('hold') || !moves.length) moves = ['hold']
const duration = Number(beat.durationSec)
return { moves, amplitude: ['subtle','moderate','large'].includes(beat.amplitude) ? beat.amplitude : 'subtle', speed: ['slow','medium','fast'].includes(beat.speed) ? beat.speed : 'slow', durationSec: beat.durationSec == null ? null : Number.isFinite(duration) ? Math.max(.5, Math.round(duration * 2) / 2) : null }
}
export function toggleMove(beat, move) {
if (!cameraMoves.includes(move)) return normalizeBeat(beat)
if (move === 'hold') return normalizeBeat({...beat,moves:['hold']})
const moves = beat.moves.filter(m => m !== 'hold')
return normalizeBeat({...beat,moves:moves.includes(move) ? moves.filter(m => m !== move) : [...moves,move]})
}
export function sectionsFrom(value = {}) {
if (typeof value === 'string') value = {action:value}
value ||= {}
const s = Object.fromEntries(['action','camera','soundscape','music','locks'].map(k => [k,typeof value[k] === 'string' ? value[k] : '']))
const legacy = value.cameraChip
s.cameraBeats = Array.isArray(value.cameraBeats) ? value.cameraBeats.map(normalizeBeat) : legacy ? [normalizeBeat({...legacy,moves:[legacy.family === 'static' ? 'hold' : legacy.family.replaceAll(' ','-')]})] : []
s.soundscapeChips = [...new Set((value.soundscapeChips || []).filter(x => soundscapeOptions.includes(x)))]
s.soundscapeLevel = value.soundscapeLevel === 'crush' ? 'crush' : 'normal'
const d = typeof value.dialogue === 'object' && value.dialogue ? value.dialogue : {mode:value.dialogue ? 'spoken to camera' : 'none',line:value.dialogue || ''}
s.dialogue = {mode:['none','spoken to camera','spoken off-screen','whisper'].includes(d.mode) ? d.mode : 'none',language:String(d.language || 'English'),speaker:String(d.speaker || 'S1'),line:String(d.line || '')}
s.musicChip = ['N/A','none','low underscore'].includes(value.musicChip) ? value.musicChip : 'N/A'
return s
}
export function compileSections(value) {
const s = sectionsFrom(value)
const camera = s.cameraBeats.length ? ['One continuous take.', ...s.cameraBeats.map((b,i) => {
const move = b.moves[0] === 'hold' ? 'hold the camera locked on a tripod' : b.moves.map(m=>m.replaceAll('-',' ')).join(' and ') + (b.moves.length > 1 ? ' simultaneously' : '')
const timing = b.durationSec == null ? 'through the end of the remaining clip' : i === 0 ? `for the first ${b.durationSec} seconds` : `for ${b.durationSec} seconds`
return `${i ? 'Then' : 'First'}, ${move}, ${{subtle:'small',moderate:'medium',large:'large'}[b.amplitude]} amplitude, ${b.speed} speed, ${timing}.`
}), 'No cuts. No new setup.'].join(' ') : s.camera
const sound = [s.soundscapeChips.length ? `${s.soundscapeChips.join(', ')}.${s.soundscapeLevel === 'crush' ? ' Crushed soundscape dynamics.' : ''}` : '',s.soundscape].filter(Boolean).join(' ')
const d=s.dialogue, line=d.line.trim()
const dialogue = d.mode !== 'none' && line ? `${d.mode === 'spoken off-screen' ? 'Off-screen voice; on-screen lips remain closed. ' : ''}<Subject 1> (${d.speaker}) ${d.mode === 'whisper' ? 'whispers' : 'says'} <d>[${d.language}]${line}</d>` : ''
const music = s.music || (s.musicChip === 'N/A' ? 'non_diegetic_music: N/A' : s.musicChip === 'none' ? 'No non-diegetic music.' : 'A low non-diegetic musical underscore.')
return [s.action,camera && `Camera: ${camera}`,sound && `Soundscape: ${sound}`,dialogue,music,s.locks && `Locks: ${s.locks}`].filter(Boolean).join('\n\n')
}
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import {compilePrompt,validateRequest,restoreInput} from '../shared/studio2/contracts.mjs'
import {normalizeBeat,toggleMove} from '../shared/studio2/prompt.mjs'
import {bindClip,restoreAsset,resolveSize,extendSelected} from '../shared/studio2/input.mjs'
import {nativeVideoGraph} from '../shared/studio2/graphs.mjs'
const base={mode:'video',engine:'minimax',folderId:'f',identityStillId:'hero',promptSections:{action:'Walk forward'}}
const beats=[normalizeBeat({moves:['tilt-down','dolly-out'],durationSec:2}),normalizeBeat({moves:['pan-left'],durationSec:null})]
test('sequential and simultaneous beats compile as one continuous take',()=>{
const text=compilePrompt({cameraBeats:beats},true)
assert.match(text,/tilt down and dolly out simultaneously/);assert.match(text,/first 2 seconds/);assert.match(text,/Then, pan left/);assert.match(text,/No cuts\. No new setup\./);assert.doesNotMatch(text,/\[Shot|Shot 2|00:02/)
})
test('hold is exclusive; toggling moves supports simultaneous motion',()=>{
const mixed=normalizeBeat({moves:['hold','pan-left']});assert.deepEqual(mixed.moves,['hold'])
const moved=toggleMove(toggleMove(mixed,'tilt-down'),'dolly-out');assert.deepEqual(moved.moves,['tilt-down','dolly-out']);assert.deepEqual(toggleMove(moved,'hold').moves,['hold'])
})
test('Use as input restores camera, sound, dialogue, music, LoRAs, aspect and locks',()=>{
const request=validateRequest({...base,lockFace:true,lockOutfit:false,hideInput:true,promptSections:{action:'Walk',cameraBeats:beats,soundscapeChips:['footsteps'],dialogue:{mode:'whisper',line:'Hello',language:'English'},musicChip:'none'},settings:{aspect:'9:16',width:768,height:1344,loraStack:[{name:'example.safetensors',strengthModel:.8,strengthClip:.4}]}})
const restored=restoreInput({studio2:request});assert.deepEqual(restored,request);assert.equal(restored.lockOutfit,false);assert.equal(restored.hideInput,true)
})
test('extend binds completed clip, inherits settings and leaves delta prompt alone',()=>{
const form={...base,lockFace:true,lockOutfit:false,settings:{duration:5,width:1344,height:768},promptSections:{action:'Turn',cameraBeats:beats}}
const clip={id:'done',kind:'clip',studio2:{...base,lockFace:true,lockOutfit:false,settings:{duration:8,width:768,height:1344,aspect:'9:16',fps:24,steps:8,cfg:1.5,turbo:false,loraStack:[{name:'a',strengthModel:1,strengthClip:.5}]}}}
const bound=bindClip(form,clip);assert.equal(bound.startClipId,'done');assert.deepEqual(bound.settings,clip.studio2.settings);assert.equal(bound.promptSections.action,'Turn');assert.equal(bound.identityStillId,'hero');assert.equal(bound.lockOutfit,false)
assert.equal(restoreAsset(form,clip).startClipId,'done');assert.equal(bindClip(form,{id:'legacy'}).lockOutfit,false)
const overridden={...bound,lockOutfit:false,settings:{...bound.settings,cfg:2.5}}
const extend=extendSelected(overridden,{...clip,studio2:{...clip.studio2,lockOutfit:true}})
assert.equal(extend.lockOutfit,false);assert.equal(extend.settings.cfg,2.5);assert.equal(extend.startClipId,'done')
})
test('appearance roles distinguish wardrobe lock and both-off graph excludes hero',()=>{
assert.match(compilePrompt({},true,{lockOutfit:false}),/Do not restore an earlier outfit/)
assert.match(compilePrompt({},true),/person and clothes fully preserved/)
const r=validateRequest({...base,lockFace:false,lockOutfit:false,imageAId:'start'})
const template=JSON.parse(fs.readFileSync(new URL('../server/assets/studio2_minimax_native.json',import.meta.url)))
const graph=nativeVideoGraph(template,r,{hero:'ignored.png',start:'start.png'},'preview')
assert.equal(graph.hero,undefined);assert.equal(graph['155'].inputs['ref_images.ref_image_0'],undefined);assert.equal(graph.load_start.inputs.image,'start.png');assert.equal(r.identityStillId,'hero')
})
test('sound/dialogue/music compile without empty invented audio',()=>{
const empty=compilePrompt({});assert.doesNotMatch(empty,/Soundscape:|<d>/);assert.match(empty,/non_diegetic_music: N\/A/)
const text=compilePrompt({soundscapeChips:['footsteps'],dialogue:{mode:'spoken off-screen',line:'Hello'}});assert.match(text,/lips remain closed/);assert.match(text,/<Subject 1> \(S1\) says <d>\[English\]Hello<\/d>/)
})
test('aspect sizing follows source and named presets',()=>{
assert.deepEqual(resolveSize('9:16',{width:1344,height:768}),{width:768,height:1344});const size=resolveSize('auto',{width:768,height:1024});assert.equal(size.width/size.height,.75)
})
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import ts from 'typescript'
test('progress accepts this client, rejects other prompts and closes on completion',()=>{
const source=fs.readFileSync(new URL('../server/utils/studio2/progress.ts',import.meta.url),'utf8')
const tree=ts.createSourceFile('progress.ts',source,ts.ScriptTarget.Latest,true)
const fn=tree.statements.find(n=>ts.isFunctionDeclaration(n)&&n.name.text==='watchProgress')
const code=ts.transpileModule(fn.getText(tree).replace(/^export /,''),{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
let socket,saves=0
class Socket { listeners={};constructor(){socket=this}addEventListener(name,fn){this.listeners[name]=fn}close(){this.closed=true} }
const watch=new Function('WebSocket','comfyWsUrl','saveRecord',`${code};return watchProgress`)(Socket,()=>'/fake',()=>saves++)
const record={promptId:'mine'},stop=watch(record,{clientId:'client'})
const send=data=>socket.listeners.message({data:JSON.stringify({type:'progress',data})})
send({prompt_id:'another',value:1,max:8});assert.equal(saves,0)
send({prompt_id:'mine',value:3,max:8});assert.equal(record.progress.step,3);assert.equal(record.progress.maxStep,8)
send({value:4,max:8});assert.equal(record.progress.percent,50)
stop();assert.equal(socket.closed,true)
})
+1 -1
View File
@@ -16,7 +16,7 @@ function fixture(mode='video') {
const job={status:'running',clientId:'client',library:{}} const job={status:'running',clientId:'client',library:{}}
let id=0 let id=0
const scope={ const scope={
compilePrompt,saveRecord(){},update(r,state){r.state=state;phases.push(state)},acquireSharedGpu:async()=>true,ensureComfyReady:async()=>{},emitJob(){}, watchProgress:()=>()=>{},compilePrompt,saveRecord(){},update(r,state){r.state=state;phases.push(state)},acquireSharedGpu:async()=>true,ensureComfyReady:async()=>{},emitJob(){},
prepareGraph:async r=>{queued.push({hero:r.request.identityStillId,start:r.request.startClipId,action:r.request.promptSections.action});return {}},queuePrompt:async()=>({prompt_id:`p${++id}`}), prepareGraph:async r=>{queued.push({hero:r.request.identityStillId,start:r.request.startClipId,action:r.request.promptSections.action});return {}},queuePrompt:async()=>({prompt_id:`p${++id}`}),
fetchHistory:async p=>({[p]:{status:{status_str:'success'},outputs:{}}}),comfyFetch:()=>{throw new Error('Unexpected network call')}, fetchHistory:async p=>({[p]:{status:{status_str:'success'},outputs:{}}}),comfyFetch:()=>{throw new Error('Unexpected network call')},
extractVideo:()=>({filename:'video.mp4',subfolder:'preview/studio2/r',type:'output'}),extractEditedImage:()=>({filename:'image.png',subfolder:'preview/studio2/r',type:'output'}), extractVideo:()=>({filename:'video.mp4',subfolder:'preview/studio2/r',type:'output'}),extractEditedImage:()=>({filename:'image.png',subfolder:'preview/studio2/r',type:'output'}),