From c719e1270950a68a6b5da16b6bc74c28e1d08e56 Mon Sep 17 00:00:00 2001 From: Towsty Date: Fri, 11 Sep 2026 09:28:00 -0500 Subject: [PATCH] Select and confirm uploaded Studio 2 source and hero stills --- components/studio2/Bench.vue | 14 ++++++++++++-- server/api/studio-2/stills.post.ts | 15 +++++++++++++++ shared/studio2/upload.mjs | 8 ++++++++ tests/studio2-upload.test.mjs | 14 ++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 server/api/studio-2/stills.post.ts create mode 100644 shared/studio2/upload.mjs create mode 100644 tests/studio2-upload.test.mjs diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue index 1473bc6..30213e9 100644 --- a/components/studio2/Bench.vue +++ b/components/studio2/Bench.vue @@ -28,7 +28,7 @@ - +

{{ uploading ? 'Uploading image…' : uploadMessage }}

Add Guide stills
@@ -57,6 +57,8 @@ import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs' import {aspectOptions,resolveSize,bindClip,restoreAsset,extendSelected} from '~/shared/studio2/input.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' const measured=reactive>({}),seenOutputs=new Set() const loraNames=ref({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 queue(){submitting.value=true;error.value='';try{if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed();const queued=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)}});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){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('/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(){ const [nextJobs,nextLibrary]=await Promise.all([$fetch('/api/studio-2/jobs'),$fetch('/api/library?light=1')]) jobs.value=nextJobs;library.value=nextLibrary diff --git a/server/api/studio-2/stills.post.ts b/server/api/studio-2/stills.post.ts new file mode 100644 index 0000000..512d511 --- /dev/null +++ b/server/api/studio-2/stills.post.ts @@ -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})} +}) diff --git a/shared/studio2/upload.mjs b/shared/studio2/upload.mjs new file mode 100644 index 0000000..e395366 --- /dev/null +++ b/shared/studio2/upload.mjs @@ -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 +} diff --git a/tests/studio2-upload.test.mjs b/tests/studio2-upload.test.mjs new file mode 100644 index 0000000..5e99831 --- /dev/null +++ b/tests/studio2-upload.test.mjs @@ -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/) +})