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 <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-26 17:06:39 -05:00
co-authored by Cursor
parent 50319e03a9
commit e06afe4a17
4 changed files with 121 additions and 41 deletions
+9 -2
View File
@@ -4,12 +4,14 @@
type="button" type="button"
class="slot-thumb" class="slot-thumb"
:class="asset ? 'slot-thumb--slot' : 'slot-thumb--empty'" :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')" @click="$emit('pick')"
> >
<span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Still loaded · preview hidden</span> <span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Still loaded · preview hidden</span>
<img v-else-if="asset" :src="thumbSrc" alt=""> <img v-else-if="asset" :src="thumbSrc" alt="">
<span v-else class="inline-flex items-center gap-1">Select <span aria-hidden="true">▾</span></span> <span v-else class="inline-flex flex-col items-center gap-1 px-2 text-center">
<span>Choose a photo</span>
</span>
</button> </button>
<div class="photo-card-body"> <div class="photo-card-body">
<label class="photo-role-label"> <label class="photo-role-label">
@@ -31,6 +33,11 @@
title="Mention this photo in the prompt" title="Mention this photo in the prompt"
@click.stop="$emit('mention')" @click.stop="$emit('mention')"
>Mention in prompt</button> >Mention in prompt</button>
<button
v-if="!readonly && asset"
type="button"
@click.stop="$emit('pick')"
>Replace</button>
<button v-if="!readonly" type="button" @click.stop="$emit('remove')">Remove</button> <button v-if="!readonly" type="button" @click.stop="$emit('remove')">Remove</button>
</div> </div>
</div> </div>
+98 -36
View File
@@ -356,10 +356,12 @@
</div> </div>
<div v-if="picker" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6" @click.self="closePicker"> <div v-if="picker" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6" @click.self="closePicker">
<section role="dialog" aria-modal="true" aria-label="Choose source asset" class="max-h-[80vh] w-full max-w-4xl overflow-auto rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel)] p-5"> <section role="dialog" aria-modal="true" aria-label="Choose a photo" class="max-h-[80vh] w-full max-w-4xl overflow-auto rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel)] p-5">
<div class="mb-4 flex justify-between"><h2>Choose a photo</h2><button type="button" @click="closePicker">Close</button></div> <div class="mb-4 flex justify-between"><h2>Choose a photo</h2><button type="button" @click="closePicker">Cancel</button></div>
<div class="mb-4 flex flex-wrap items-center gap-3"> <div class="mb-4 flex flex-wrap items-center gap-3">
<button type="button" class="text-[var(--type-sm)] text-[var(--muted)]" @click="pickAsset(null)">Clear slot</button> <label class="slot-upload">Upload from disk
<input type="file" accept="image/*" class="sr-only" :disabled="uploading" @change="uploadPickerFile">
</label>
<button <button
v-if="xaigen && pickerAssets.length" v-if="xaigen && pickerAssets.length"
type="button" type="button"
@@ -376,6 +378,7 @@
<span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span> <span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span>
</button> </button>
</div> </div>
<p v-if="!pickerAssets.length" class="text-[var(--type-sm)] text-[var(--muted)]">No stills in this folder yet. Upload from disk above.</p>
</section> </section>
</div> </div>
</div> </div>
@@ -663,21 +666,35 @@ function scrollFilmstripToNewest(){
else if(strip)strip.scrollLeft=strip.scrollWidth 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(){ function syncPhotosBindings(){
const next=applyPhotosToRequest({...form,photos:form.photos}) const filled=(form.photos||[]).filter((p:any)=>p?.stillId)
form.photos=next.photos||[] 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.imageAId=next.imageAId||''
form.imageBId=next.imageBId||'' form.imageBId=next.imageBId||''
form.identityStillId=form.engine==='qwen21'?'':(next.identityStillId||'') form.identityStillId=form.engine==='qwen21'?'':(next.identityStillId||'')
} }
function addPhoto(){ function addPhoto(){
const plan=photosPlanState.value const plan=photosPlanState.value
if(!plan.show||form.photos.length>=plan.max)return if(!plan.show||plan.handoffReadonly)return
picker.value='photo-add' 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){ function removePhoto(index:number){
if(!Number.isInteger(index)||index<0||index>=form.photos.length)return
form.photos.splice(index,1) form.photos.splice(index,1)
// Explicit empty photos array clears imageAId / imageBId / identityStillId.
syncPhotosBindings() syncPhotosBindings()
} }
function setPhotoRole(index:number,role:string){ function setPhotoRole(index:number,role:string){
@@ -685,6 +702,19 @@ function setPhotoRole(index:number,role:string){
form.photos[index].role=role form.photos[index].role=role
syncPhotosBindings() 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(){ function capturePromptCaret(){
const el=promptArea.value const el=promptArea.value
if(!el)return if(!el)return
@@ -818,9 +848,69 @@ function toggleReveal(id:string){
if(import.meta.client)localStorage.setItem(`studio2-revealed:${form.folderId}`,JSON.stringify([...next])) if(import.meta.client)localStorage.setItem(`studio2-revealed:${form.folderId}`,JSON.stringify([...next]))
} }
function closePicker(){ function closePicker(){
const key=picker.value
if(key.startsWith('photo:'))discardEmptyPhotoDraft(Number(key.slice(6)))
picker.value='' picker.value=''
pickerRevealAll.value=false 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<any>('/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<any>('/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){ function loadRevealed(folderId:string){
if(!import.meta.client||!xaigen){revealed.value=new Set();return} if(!import.meta.client||!xaigen){revealed.value=new Set();return}
try{revealed.value=new Set(JSON.parse(localStorage.getItem(`studio2-revealed:${folderId}`)||'[]'))} 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 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} 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.'}} 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 clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
async function deleteOutput(){ async function deleteOutput(){
if(!selected.value)return if(!selected.value)return
@@ -907,14 +977,6 @@ async function queue(){
finally{submitting.value=false} 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,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<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,nextUpscales,queue]=await Promise.all([ const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([
+4 -1
View File
@@ -301,6 +301,8 @@ export function photosFromRequest(request = {}) {
/** /**
* Apply Photos → request fields. Call before validate / queue. * Apply Photos → request fields. Call before validate / queue.
* If Edit+Qwen has cards but no Photo to change, promote the first card. * 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 = {}) { export function applyPhotosToRequest(raw = {}) {
const next = { ...raw } const next = { ...raw }
@@ -309,8 +311,9 @@ export function applyPhotosToRequest(raw = {}) {
if (!Array.isArray(next.photos)) next.photos = [] if (!Array.isArray(next.photos)) next.photos = []
return next return next
} }
const photosProvided = Array.isArray(raw.photos)
let photos = normalizePhotos(next.photos, plan) 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) photos = normalizePhotos(photosFromRequest(next), plan)
} }
// Promote first card to Photo to change when Edit needs it. // Promote first card to Photo to change when Edit needs it.
+10 -2
View File
@@ -9,7 +9,7 @@ import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
import {cachedLoras} from '../shared/studio2/lora-cache.mjs' import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs' import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
import {ensureQwen21EditPrompt,stitchQwen21PeEditPrompt,QWEN21_EDIT_KEEP_CHANGE} from '../shared/studio2/qwen21-edit.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' import {qwen21PeRefusal} from '../shared/studio2/qwen21-pe.mjs'
test('deleted library files never return from job outputs; selection falls back or clears',()=>{ 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.equal(qwenPhotos.identityStillId,'')
assert.match(qwenPhotos.compiledPrompt,/Put her in that dress/) assert.match(qwenPhotos.compiledPrompt,/Put her in that dress/)
assert.doesNotMatch(String(qwenPhotos.promptSections.action),/<image1>/) assert.doesNotMatch(String(qwenPhotos.promptSections.action),/<image1>/)
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:'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.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`) 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,/photosPlanState\.emptyHint/)
assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/) 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(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,/Hero still/)
assert.doesNotMatch(src,/Edit source/) assert.doesNotMatch(src,/Edit source/)
assert.doesNotMatch(src,/Identity \/ reference/) assert.doesNotMatch(src,/Identity \/ reference/)