diff --git a/components/studio2/AssetSlot.vue b/components/studio2/AssetSlot.vue
index 1c551b9..74a2b8a 100644
--- a/components/studio2/AssetSlot.vue
+++ b/components/studio2/AssetSlot.vue
@@ -4,12 +4,14 @@
type="button"
class="slot-thumb"
:class="asset ? 'slot-thumb--slot' : 'slot-thumb--empty'"
- :aria-label="asset ? 'Replace photo' : 'Select photo'"
+ :aria-label="asset ? 'Replace photo' : 'Choose a photo'"
@click="$emit('pick')"
>
Still loaded · preview hidden
- Select ▾
+
+ Choose a photo
+
diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue
index d504453..ce18fa2 100644
--- a/components/studio2/Bench.vue
+++ b/components/studio2/Bench.vue
@@ -356,10 +356,12 @@
-
- Choose a photo
+
+ Choose a photo
-
+
+ No stills in this folder yet. Upload from disk above.
@@ -663,21 +666,35 @@ function scrollFilmstripToNewest(){
else if(strip)strip.scrollLeft=strip.scrollWidth
})
}
-function stillById(id:string){return stills.value.find((s:any)=>s.id===id)||null}
+function stillById(id:string){return id?stills.value.find((s:any)=>s.id===id)||null:null}
+/** Photos array is source of truth — never resurrect cards from leftover imageAId. */
function syncPhotosBindings(){
- const next=applyPhotosToRequest({...form,photos:form.photos})
- form.photos=next.photos||[]
+ const filled=(form.photos||[]).filter((p:any)=>p?.stillId)
+ const next=applyPhotosToRequest({
+ ...form,
+ photos:filled,
+ imageAId:'',
+ imageBId:'',
+ identityStillId:'',
+ })
+ // Keep empty draft cards in the UI; only overwrite filled bindings.
+ const drafts=(form.photos||[]).filter((p:any)=>!p?.stillId)
+ form.photos=[...(next.photos||[]),...drafts]
form.imageAId=next.imageAId||''
form.imageBId=next.imageBId||''
form.identityStillId=form.engine==='qwen21'?'':(next.identityStillId||'')
}
function addPhoto(){
const plan=photosPlanState.value
- if(!plan.show||form.photos.length>=plan.max)return
- picker.value='photo-add'
+ if(!plan.show||plan.handoffReadonly)return
+ if(form.photos.length>=plan.max)return
+ form.photos.push({stillId:'',role:plan.defaultRole||'change'})
+ picker.value='photo:'+(form.photos.length-1)
}
function removePhoto(index:number){
+ if(!Number.isInteger(index)||index<0||index>=form.photos.length)return
form.photos.splice(index,1)
+ // Explicit empty photos array clears imageAId / imageBId / identityStillId.
syncPhotosBindings()
}
function setPhotoRole(index:number,role:string){
@@ -685,6 +702,19 @@ function setPhotoRole(index:number,role:string){
form.photos[index].role=role
syncPhotosBindings()
}
+function setPhotoStill(index:number,stillId:string){
+ if(!Number.isInteger(index)||!form.photos[index])return
+ if(!stillId){
+ form.photos.splice(index,1)
+ }else{
+ form.photos[index].stillId=stillId
+ }
+ syncPhotosBindings()
+}
+function discardEmptyPhotoDraft(index:number){
+ if(!Number.isInteger(index)||!form.photos[index])return
+ if(!form.photos[index].stillId)form.photos.splice(index,1)
+}
function capturePromptCaret(){
const el=promptArea.value
if(!el)return
@@ -818,9 +848,69 @@ function toggleReveal(id:string){
if(import.meta.client)localStorage.setItem(`studio2-revealed:${form.folderId}`,JSON.stringify([...next]))
}
function closePicker(){
+ const key=picker.value
+ if(key.startsWith('photo:'))discardEmptyPhotoDraft(Number(key.slice(6)))
picker.value=''
pickerRevealAll.value=false
}
+function pickAsset(asset:any){
+ const key=picker.value
+ if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||''
+ else if(key==='startClipId')chooseClip(asset?.id||'')
+ else if(key.startsWith('photo:')){
+ const index=Number(key.slice(6))
+ if(asset?.id)setPhotoStill(index,asset.id)
+ else discardEmptyPhotoDraft(index)
+ }else if(key) form[key]=asset?.id||''
+ picker.value=''
+ pickerRevealAll.value=false
+}
+async function uploadPickerFile(event:Event){
+ const input=event.target as HTMLInputElement
+ const file=input.files?.[0]
+ if(!file)return
+ const key=picker.value
+ uploading.value=true;uploadMessage.value='';error.value=''
+ const body=new FormData();body.append('image',file);body.append('folderId',form.folderId);body.append('hideInput',String(xaigen))
+ try{
+ const response=await $fetch('/api/studio-2/stills',{method:'POST',body})
+ library.value=response
+ measured[response.still.id]={width:response.still.width,height:response.still.height}
+ if(key.startsWith('photo:')){
+ setPhotoStill(Number(key.slice(6)),response.still.id)
+ picker.value=''
+ pickerRevealAll.value=false
+ }else{
+ Object.assign(form,applyUploadedStill(form,response,'source'))
+ }
+ uploadMessage.value=`Loaded ${response.still.name || file.name}.`
+ }catch(e:any){error.value=e.data?.statusMessage||e.message}
+ finally{uploading.value=false;input.value=''}
+}
+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(xaigen))
+ try{
+ const response=await $fetch('/api/studio-2/stills',{method:'POST',body})
+ library.value=response
+ measured[response.still.id]={width:response.still.width,height:response.still.height}
+ const plan=photosPlanState.value
+ if(plan.show && !plan.handoffReadonly){
+ const role=target==='hero'
+ ?(plan.roles.includes('person')?'person':plan.defaultRole)
+ :plan.defaultRole
+ if(form.photos.length>=plan.max)form.photos[form.photos.length-1]={stillId:response.still.id,role}
+ else form.photos.push({stillId:response.still.id,role:role||'change'})
+ syncPhotosBindings()
+ }else{
+ Object.assign(form,applyUploadedStill(form,response,target))
+ }
+ uploadMessage.value=`Loaded ${response.still.name || file.name}.`
+ }
+ catch(e:any){error.value=e.data?.statusMessage||e.message}
+ finally{uploading.value=false;input.value=''}
+}
function loadRevealed(folderId:string){
if(!import.meta.client||!xaigen){revealed.value=new Set();return}
try{revealed.value=new Set(JSON.parse(localStorage.getItem(`studio2-revealed:${folderId}`)||'[]'))}
@@ -851,26 +941,6 @@ function selectOutput(item:any){selected.value=item;if(form.mode==='extend' && i
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null);focusedJobId.value=job.id}
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
-function pickAsset(asset:any){
- const key=picker.value
- if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||''
- else if(key==='startClipId')chooseClip(asset?.id||'')
- else if(key==='photo-add'){
- if(asset?.id){
- const plan=photosPlanState.value
- form.photos.push({stillId:asset.id,role:plan.defaultRole||'change'})
- syncPhotosBindings()
- }
- }else if(key.startsWith('photo:')){
- const index=Number(key.slice(6))
- if(Number.isInteger(index)&&form.photos[index]){
- if(asset?.id)form.photos[index].stillId=asset.id
- else form.photos.splice(index,1)
- syncPhotosBindings()
- }
- }else if(key) form[key]=asset?.id||''
- closePicker()
-}
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
async function deleteOutput(){
if(!selected.value)return
@@ -907,14 +977,6 @@ async function queue(){
finally{submitting.value=false}
}
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refresh()}
-async function upload(event:Event,target:'source'|'hero'){
- const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return
- uploading.value=true;uploadMessage.value='';error.value=''
- const body=new FormData();body.append('image',file);body.append('folderId',form.folderId);body.append('hideInput',String(xaigen))
- 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,nextUpscales,queue]=await Promise.all([
diff --git a/shared/studio2/photos.mjs b/shared/studio2/photos.mjs
index be3c081..d7506a5 100644
--- a/shared/studio2/photos.mjs
+++ b/shared/studio2/photos.mjs
@@ -301,6 +301,8 @@ export function photosFromRequest(request = {}) {
/**
* Apply Photos → request fields. Call before validate / queue.
* If Edit+Qwen has cards but no Photo to change, promote the first card.
+ * When `photos` is an explicit array (even empty), it is source of truth —
+ * do not resurrect cards from leftover imageAId / identityStillId.
*/
export function applyPhotosToRequest(raw = {}) {
const next = { ...raw }
@@ -309,8 +311,9 @@ export function applyPhotosToRequest(raw = {}) {
if (!Array.isArray(next.photos)) next.photos = []
return next
}
+ const photosProvided = Array.isArray(raw.photos)
let photos = normalizePhotos(next.photos, plan)
- if (!photos.length && (next.imageAId || next.identityStillId || next.imageBId)) {
+ if (!photosProvided && !photos.length && (next.imageAId || next.identityStillId || next.imageBId)) {
photos = normalizePhotos(photosFromRequest(next), plan)
}
// Promote first card to Photo to change when Edit needs it.
diff --git a/tests/studio2-bench.test.mjs b/tests/studio2-bench.test.mjs
index e7f4316..f173da0 100644
--- a/tests/studio2-bench.test.mjs
+++ b/tests/studio2-bench.test.mjs
@@ -9,7 +9,7 @@ import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
import {ensureQwen21EditPrompt,stitchQwen21PeEditPrompt,QWEN21_EDIT_KEEP_CHANGE} from '../shared/studio2/qwen21-edit.mjs'
-import {injectQwenEditMentionTags} from '../shared/studio2/photos.mjs'
+import {injectQwenEditMentionTags,applyPhotosToRequest} from '../shared/studio2/photos.mjs'
import {qwen21PeRefusal} from '../shared/studio2/qwen21-pe.mjs'
test('deleted library files never return from job outputs; selection falls back or clears',()=>{
@@ -109,6 +109,11 @@ test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
assert.equal(qwenPhotos.identityStillId,'')
assert.match(qwenPhotos.compiledPrompt,/Put her in that dress/)
assert.doesNotMatch(String(qwenPhotos.promptSections.action),//)
+ const afterRemove=applyPhotosToRequest({mode:'edit',engine:'qwen21',photos:[],imageAId:'stale-person',imageBId:'stale-dress'})
+ assert.equal(afterRemove.imageAId,'')
+ assert.equal(afterRemove.imageBId,'')
+ assert.deepEqual(afterRemove.photos,[])
+ assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',photos:[],imageAId:'stale',promptSections:{action:'x'}}),/Photo to change/)
assert.throws(()=>validateRequest({mode:'compose',engine:'qwen21',folderId:'f',imageAId:'s',imageBId:'b',promptSections:{action:'x'}}),/Generate and Edit/)
assert.throws(()=>validateRequest({mode:'iterate',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/Generate and Edit/)
assert.equal(ensureQwen21EditPrompt('put her in a red leather jacket'),`${QWEN21_EDIT_KEEP_CHANGE} put her in a red leather jacket`)
@@ -167,7 +172,10 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio
assert.match(src,/photosPlanState\.emptyHint/)
assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/)
assert.match(src,/Enhance prompt/);assert.match(src,/enhancePrompt/)
- assert.match(slot,/Mention in prompt/);assert.match(slot,/This photo is/)
+ assert.match(slot,/Mention in prompt/);assert.match(slot,/This photo is/);assert.match(slot,/Replace/)
+ assert.match(slot,/Choose a photo/)
+ assert.match(src,/uploadPickerFile/);assert.match(src,/setPhotoStill/);assert.match(src,/discardEmptyPhotoDraft/)
+ assert.match(src,/Upload from disk/)
assert.doesNotMatch(src,/Hero still/)
assert.doesNotMatch(src,/Edit source/)
assert.doesNotMatch(src,/Identity \/ reference/)