Select and confirm uploaded Studio 2 source and hero stills

This commit is contained in:
Towsty
2026-09-11 09:28:00 -05:00
parent bf58732690
commit c719e12709
4 changed files with 49 additions and 2 deletions
+12 -2
View File
@@ -28,7 +28,7 @@
<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==='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="form.mode==='extend'">Previous clip<select :value="form.startClipId" @change="chooseClip(($event.target as HTMLSelectElement).value)" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Choose a clip</option><option v-for="c in clips" :key="c.id" :value="c.id">{{ c.name || c.prompt?.slice(0,50) }}</option></select></label>
<label v-if="video">Optional end still<select v-model="form.endStillId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">None</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ assetLabel(s) }}</option></select></label> <label v-if="video">Optional end still<select v-model="form.endStillId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">None</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ assetLabel(s) }}</option></select></label>
<label class="text-sm">Add still to folder<input type="file" accept="image/*" class="mt-2 block text-xs" @change="upload"></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> </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> <details v-if="video" class="rounded-xl border border-white/10 p-3"><summary>Add Guide stills</summary><div v-for="(g,i) in form.guides" :key="i" class="mt-2 flex gap-2"><select v-model="g.stillId" class="min-w-0 flex-1 bg-zinc-800 p-2"><option value="">Choose guide</option><option v-for="s in stills" :key="s.id" :value="s.id">{{ assetLabel(s) }}</option></select><input v-model.number="g.frame" type="number" min="1" aria-label="Guide frame" class="w-20 bg-zinc-800 p-2"><button @click="form.guides.splice(i,1)">Remove</button></div><button class="mt-3 text-amber-200" @click="form.guides.push({stillId:'',frame:24})">Add guide</button></details>
</section> </section>
@@ -57,6 +57,8 @@
import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs' import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs'
import {aspectOptions,resolveSize,bindClip,restoreAsset,extendSelected} from '~/shared/studio2/input.mjs' import {aspectOptions,resolveSize,bindClip,restoreAsset,extendSelected} from '~/shared/studio2/input.mjs'
import {folderAssets,newestOutput,assetLabel} from '~/shared/studio2/viewer.mjs' import {folderAssets,newestOutput,assetLabel} from '~/shared/studio2/viewer.mjs'
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
const uploading=ref(false),uploadMessage=ref('')
import {randomSeed} from '~/shared/studio2/seed.mjs' import {randomSeed} from '~/shared/studio2/seed.mjs'
const measured=reactive<Record<string,{width:number;height:number}>>({}),seenOutputs=new Set<string>() const measured=reactive<Record<string,{width:number;height:number}>>({}),seenOutputs=new Set<string>()
const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),gpuLabel=ref('Checking…') const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),gpuLabel=ref('Checking…')
@@ -83,7 +85,15 @@ function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null)}
async function loadLoras(){loraNames.value=await $fetch('/api/loras').catch(()=>({image:[],video:[]}))} async function loadLoras(){loraNames.value=await $fetch('/api/loras').catch(()=>({image:[],video:[]}))}
async function queue(){submitting.value=true;error.value='';try{if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed();const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body:{...form,settings:{...form.settings,...resolvedSize.value},batch:batchText.value.split('\n').map(s=>s.trim()).filter(Boolean)}});form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height;await refresh()}catch(e:any){error.value=e.data?.statusMessage||e.message}finally{submitting.value=false}} async function queue(){submitting.value=true;error.value='';try{if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed();const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body:{...form,settings:{...form.settings,...resolvedSize.value},batch:batchText.value.split('\n').map(s=>s.trim()).filter(Boolean)}});form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height;await refresh()}catch(e:any){error.value=e.data?.statusMessage||e.message}finally{submitting.value=false}}
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refresh()} async function 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,target:'source'|'hero'){
const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return
uploading.value=true;uploadMessage.value='';error.value=''
const body=new FormData();body.append('image',file);body.append('folderId',form.folderId);body.append('hideInput',String(form.hideInput))
try{const response=await $fetch<any>('/api/studio-2/stills',{method:'POST',body});library.value=response;Object.assign(form,applyUploadedStill(form,response,target));measured[response.still.id]={width:response.still.width,height:response.still.height};uploadMessage.value=`Loaded ${response.still.name || file.name} as ${target==='hero'?'hero identity':'start still'}.`}
catch(e:any){error.value=e.data?.statusMessage||e.message}
finally{uploading.value=false;input.value=''}
}
async function refresh(){ 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/library?light=1')])
jobs.value=nextJobs;library.value=nextLibrary jobs.value=nextJobs;library.value=nextLibrary
+15
View File
@@ -0,0 +1,15 @@
import { imageDimensions } from '../../utils/resolution'
export default defineEventHandler(async event => {
const parts=await readMultipartFormData(event)
const image=parts?.find(p=>p.name==='image' && p.filename && p.data?.length)
const field=(name:string)=>parts?.find(p=>p.name===name)?.data.toString('utf8') || ''
if (!image) throw createError({statusCode:400,statusMessage:'Choose an image to upload.'})
const {owner}=assertLibraryOwner(event),folderId=field('folderId')
assertFolderAccess(event,folderId)
const size=imageDimensions(image.data)
if (!size) throw createError({statusCode:400,statusMessage:'Use a PNG, JPEG, WebP, GIF, or BMP image.'})
const filename=image.filename!.split(/[\\/]/).at(-1)!
const still=await saveStill({ownerKey:owner,folderId,filename,name:filename.replace(/\.[^.]+$/,''),data:image.data,...size,hideInput:field('hideInput')==='true',role:'input'})
if (!still) throw createError({statusCode:400,statusMessage:'This file was recognized as an internal video handoff frame and was not added. Choose a regular source image.'})
return {still,...publicLibrary(event,{light:true})}
})
+8
View File
@@ -0,0 +1,8 @@
export function applyUploadedStill(form, response, target) {
const still=response?.still
if (!still?.id) throw new Error('The upload did not return a saved still. Please try again.')
const next={...form,settings:{...form.settings}}
if (target==='hero') next.identityStillId=still.id
else {next.imageAId=still.id;next.startClipId='';if(next.mode==='extend')next.mode='video';next.settings.aspect='auto'}
return next
}
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {applyUploadedStill} from '../shared/studio2/upload.mjs'
test('uploaded source is selected without changing hero or output',()=>{
const form={imageAId:'old',identityStillId:'hero',selected:'result',startClipId:'clip',mode:'extend',settings:{aspect:'16:9'}}
const next=applyUploadedStill(form,{still:{id:'uploaded'}},'source')
assert.equal(next.imageAId,'uploaded');assert.equal(next.identityStillId,'hero');assert.equal(next.selected,'result');assert.equal(next.startClipId,'');assert.equal(next.settings.aspect,'auto')
})
test('uploaded hero changes identity only and missing saves are reported',()=>{
const form={identityStillId:'old',imageAId:'source',settings:{aspect:'1:1'}}
const next=applyUploadedStill(form,{still:{id:'hero'}},'hero')
assert.equal(next.identityStillId,'hero');assert.equal(next.imageAId,'source');assert.equal(next.settings.aspect,'1:1')
assert.throws(()=>applyUploadedStill(form,{},'hero'),/did not return a saved still/)
})