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/)
+})