From e06afe4a17d3efb38c4db2780e97128afdf0b2b8 Mon Sep 17 00:00:00 2001 From: Towsty Date: Sat, 26 Sep 2026 17:06:39 -0500 Subject: [PATCH] Fix Photos Add, Remove, and Replace against the library picker. Remove clears the Photos array and backing image fields; Add opens the picker with disk upload and drops empty drafts on cancel. Co-authored-by: Cursor --- components/studio2/AssetSlot.vue | 11 ++- components/studio2/Bench.vue | 134 ++++++++++++++++++++++--------- shared/studio2/photos.mjs | 5 +- tests/studio2-bench.test.mjs | 12 ++- 4 files changed, 121 insertions(+), 41 deletions(-) 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 @@
-
@@ -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/)