Choose a photo
diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue
index cf5d13e..958d11c 100644
--- a/components/studio2/Bench.vue
+++ b/components/studio2/Bench.vue
@@ -89,24 +89,13 @@
>{{ hideInputs ? 'Show inputs' : 'Hide inputs' }}
Continues from last frame
-
-
- {{ photoFilledCount }} photo{{ photoFilledCount===1 ? '' : 's' }}
-
-
-
{{ photosPlanState.emptyHint }}
+
+
+
LoRA
-
+
+
-
+
+
+
@@ -318,9 +313,12 @@
:preview-hidden="!eyeOpen"
@close="libraryOpen=false"
@select="selectOutput"
- @use-as-photo="onLibraryUseAsPhoto"
+ @use-image="onLibraryUseImage"
+ @use-prompt="onLibraryUsePrompt"
@copy-prompt="onLibraryCopyPrompt"
- @load="onLibraryLoad"
+ @copy-raw-prompt="onLibraryCopyRawPrompt"
+ @load-input="onLibraryLoadInput"
+ @load-input-raw="onLibraryLoadInputRaw"
@delete="onLibraryDelete"
@toggle-preview="toggleReveal()"
/>
@@ -451,6 +449,8 @@ import {LOAD_GENERATION_PAYLOAD_KEY} from '~/utils/queuedJob'
const NEG_STORAGE_KEY='studio2-negative-chips'
const ENHANCE_STORAGE_KEY='studio2-qwen21-enhance-prompt'
const TURBO_STORAGE_KEY='studio2-qwen21-turbo'
+const BENCH_DRAFT_KEY='aigen:bench-draft'
+let draftTimer:ReturnType|null=null
function readStoredNegatives(){
if(!import.meta.client)return [...defaultNegatives]
try{
@@ -480,6 +480,84 @@ function writeStoredTurbo(on:boolean){
if(!import.meta.client)return
try{localStorage.setItem(TURBO_STORAGE_KEY,on?'1':'0')}catch{/* ignore */}
}
+function benchDraftPayload(){
+ return {
+ v:1,
+ mode:form.mode,
+ engine:form.engine,
+ folderId:form.folderId,
+ enhancePrompt:!!form.enhancePrompt,
+ turbo:!!form.turbo,
+ lockFace:!!form.lockFace,
+ lockOutfit:!!form.lockOutfit,
+ promptSections:structuredClone(form.promptSections||{}),
+ photos:(form.photos||[]).filter((p:any)=>p?.stillId).map((p:any)=>({stillId:p.stillId,role:p.role})),
+ imageStyles:structuredClone(form.imageStyles||{positive:[],negative:[]}),
+ settings:{
+ aspect:form.settings?.aspect||'auto',
+ seed:form.settings?.seed ?? null,
+ seedMode:form.settings?.seedMode||'random',
+ steps:form.settings?.steps,
+ cfg:form.settings?.cfg,
+ duration:form.settings?.duration,
+ fps:form.settings?.fps,
+ loraStack:structuredClone(form.settings?.loraStack||[])
+ },
+ eyeOpen:!!eyeOpen.value,
+ hideInputs:!!hideInputs.value,
+ music:structuredClone({tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed,name:music.name})
+ }
+}
+function saveBenchDraft(immediate=false){
+ if(!import.meta.client)return
+ const write=()=>{
+ try{localStorage.setItem(BENCH_DRAFT_KEY,JSON.stringify(benchDraftPayload()))}catch{/* ignore */}
+ }
+ if(draftTimer)clearTimeout(draftTimer)
+ if(immediate){write();return}
+ draftTimer=setTimeout(write,400)
+}
+function restoreBenchDraft(){
+ if(!import.meta.client)return
+ let raw=''
+ try{raw=localStorage.getItem(BENCH_DRAFT_KEY)||''}catch{return}
+ if(!raw)return
+ let draft:any
+ try{draft=JSON.parse(raw)}catch{return}
+ if(!draft||draft.v!==1)return
+ if(draft.mode)form.mode=draft.mode
+ if(draft.engine)form.engine=draft.engine
+ if(draft.folderId)form.folderId=draft.folderId
+ if(draft.enhancePrompt!=null)form.enhancePrompt=!!draft.enhancePrompt
+ if(draft.turbo!=null)form.turbo=!!draft.turbo
+ if(draft.lockFace!=null)form.lockFace=!!draft.lockFace
+ if(draft.lockOutfit!=null)form.lockOutfit=!!draft.lockOutfit
+ if(draft.promptSections)form.promptSections=sectionsFrom(draft.promptSections)
+ if(Array.isArray(draft.photos))form.photos=draft.photos.filter((p:any)=>p?.stillId).map((p:any)=>({stillId:String(p.stillId),role:String(p.role||'change')}))
+ if(draft.imageStyles)form.imageStyles={positive:draft.imageStyles.positive||[],negative:draft.imageStyles.negative||[]}
+ if(draft.settings){
+ Object.assign(form.settings,{
+ aspect:draft.settings.aspect||form.settings.aspect,
+ seed:draft.settings.seed ?? form.settings.seed,
+ seedMode:draft.settings.seedMode||form.settings.seedMode,
+ steps:draft.settings.steps ?? form.settings.steps,
+ cfg:draft.settings.cfg ?? form.settings.cfg,
+ duration:draft.settings.duration ?? form.settings.duration,
+ fps:draft.settings.fps ?? form.settings.fps,
+ loraStack:Array.isArray(draft.settings.loraStack)?draft.settings.loraStack:form.settings.loraStack
+ })
+ }
+ if(draft.eyeOpen!=null)eyeOpen.value=!!draft.eyeOpen
+ if(draft.hideInputs!=null)hideInputs.value=!!draft.hideInputs
+ if(draft.music){
+ music.tags=String(draft.music.tags||music.tags)
+ music.lyrics=String(draft.music.lyrics||music.lyrics)
+ if(draft.music.duration!=null)music.duration=Number(draft.music.duration)||music.duration
+ if(draft.music.seed!=null)music.seed=String(draft.music.seed)
+ if(draft.music.name!=null)music.name=String(draft.music.name)
+ }
+ syncPhotosBindings()
+}
const xaigen=isXaigenStudio(useRuntimeConfig().public)
const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen')
@@ -560,13 +638,19 @@ watch(()=>[form.mode,form.engine],([mode,engine])=>{
form.lockOutfit=false
}
const plan=photosPlan({mode,engine})
- if(!plan.show){form.photos=[];form.imageAId='';form.imageBId='';if(engine==='qwen21')form.identityStillId='';return}
+ if(!plan.show){
+ if(engine==='qwen21')form.identityStillId=''
+ // Keep photos + prompt; section may hide for this mode.
+ saveBenchDraft(true)
+ return
+ }
const allowed=new Set(plan.roles)
- form.photos=form.photos
+ form.photos=(form.photos||[])
.filter((p:any)=>p?.stillId)
.slice(0,plan.max)
.map((p:any)=>({stillId:p.stillId,role:allowed.has(p.role)?p.role:plan.defaultRole}))
syncPhotosBindings()
+ saveBenchDraft(true)
})
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.imageBId='';form.photos=[];form.startClipId='';loadRevealed(id);if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
@@ -718,17 +802,20 @@ function addPhoto(){
if(form.photos.length>=plan.max)return
form.photos.push({stillId:'',role:plan.defaultRole||'change'})
picker.value='photo:'+(form.photos.length-1)
+ saveBenchDraft(true)
}
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()
+ saveBenchDraft(true)
}
function setPhotoRole(index:number,role:string){
if(!form.photos[index])return
form.photos[index].role=role
syncPhotosBindings()
+ saveBenchDraft(true)
}
function setPhotoStill(index:number,stillId:string){
if(!Number.isInteger(index)||!form.photos[index])return
@@ -738,6 +825,7 @@ function setPhotoStill(index:number,stillId:string){
form.photos[index].stillId=stillId
}
syncPhotosBindings()
+ saveBenchDraft(true)
}
function discardEmptyPhotoDraft(index:number){
if(!Number.isInteger(index)||!form.photos[index])return
@@ -765,33 +853,118 @@ function mentionPhoto(index:number){
promptCaret.value={start:next.selectionStart,end:next.selectionEnd}
})
}
-function applyUseAsSource(){
- if(!selected.value||selected.value.kind==='track')return
+function usedPromptOf(item:any){
+ return String(
+ item?.studio2?.prompt
+ || item?.prompt
+ || item?.studio2?.compiledPrompt
+ || item?.studio2?.promptSections?.action
+ || ''
+ ).trim()
+}
+function rawPromptOf(item:any){
+ return String(item?.promptRaw || item?.studio2?.promptRaw || '').trim()
+}
+function sourceStillIdsOf(item:any){
+ const meta=item?.studio2 || {}
+ const ids:string[]=[]
+ const push=(id:any)=>{const v=String(id||'').trim();if(v && !ids.includes(v))ids.push(v)}
+ push(meta.sourceStillId)
+ push(meta.imageAId)
+ for(const card of meta.photos||[]) push(card?.stillId)
+ if(meta.imageBId) push(meta.imageBId)
+ return ids.filter(id=>stillById(id))
+}
+function useImageRole(){
const plan=photosPlanState.value
- if(!plan.show||plan.handoffReadonly){
- Object.assign(form,useAsSource(form,selected.value))
- uploadMessage.value=`Using ${assetLabel(selected.value)}.`
+ const filled=(form.photos||[]).filter((p:any)=>p?.stillId)
+ if((form.mode==='edit' || video.value) && !filled.length && plan.roles.includes('change')) return 'change'
+ if(plan.roles.includes('extra')) return 'extra'
+ if(plan.roles.includes('outfit')) return 'outfit'
+ return plan.defaultRole || 'change'
+}
+function applyUseImage(item?:any){
+ const asset=item || selected.value
+ if(!asset||asset.kind==='track')return
+ const plan=photosPlanState.value
+ const role=useImageRole()
+ if(!plan.show){
+ form.photos=[{stillId:asset.id,role}]
+ syncPhotosBindings()
+ uploadMessage.value=`Added ${assetLabel(asset)} to Photos.`
+ saveBenchDraft(true)
return
}
if(form.photos.length>=plan.max){
- form.photos[form.photos.length-1]={stillId:selected.value.id,role:plan.defaultRole||form.photos.at(-1)?.role||'change'}
+ form.photos[form.photos.length-1]={stillId:asset.id,role}
}else{
- form.photos.push({stillId:selected.value.id,role:plan.defaultRole||'change'})
+ form.photos.push({stillId:asset.id,role})
}
syncPhotosBindings()
- uploadMessage.value=`Added ${assetLabel(selected.value)} to Photos.`
+ uploadMessage.value=`Added ${assetLabel(asset)} to Photos.`
+ saveBenchDraft(true)
}
-async function copySelectedPrompt(){
- const text=selected.value?.studio2?.prompt
- || selected.value?.studio2?.compiledPrompt
- || selected.value?.prompt
- || selected.value?.studio2?.promptSections?.action
- || ''
+function applyUsePrompt(item?:any){
+ const asset=item || selected.value
+ if(!asset)return
+ const text=usedPromptOf(asset)
+ form.promptSections.action=text
+ uploadMessage.value=text?'Prompt set from this item.':'No Used prompt on this item.'
+ saveBenchDraft(true)
+}
+async function copySelectedPrompt(item?:any){
+ const asset=item || selected.value
+ const text=usedPromptOf(asset)
try{
if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||''))
uploadMessage.value=text?'Prompt copied.':'No prompt on this item.'
}catch{error.value='Could not copy prompt.'}
}
+async function copySelectedRawPrompt(item?:any){
+ const asset=item || selected.value
+ const raw=rawPromptOf(asset)
+ const used=usedPromptOf(asset)
+ const text=raw || used
+ try{
+ if(import.meta.client && navigator.clipboard)await navigator.clipboard.writeText(String(text||''))
+ uploadMessage.value=raw?'Raw prompt copied.':(used?'Used prompt copied (no Typed on this item).':'No prompt on this item.')
+ }catch{error.value='Could not copy prompt.'}
+}
+function loadInputPhotos(ids:string[]){
+ if(!ids.length)return
+ const plan=photosPlanState.value
+ const roles=plan.roles?.length?plan.roles:['change']
+ form.photos=ids.slice(0,plan.max || ids.length).map((id,i)=>({
+ stillId:id,
+ role:i===0 && roles.includes('change')?'change':(roles.includes('extra')?'extra':roles[Math.min(i,roles.length-1)]||'change')
+ }))
+ syncPhotosBindings()
+}
+function loadInputUsed(item?:any){
+ const asset=item || selected.value
+ if(!asset)return
+ const ids=sourceStillIdsOf(asset)
+ const text=usedPromptOf(asset)
+ if(ids.length) loadInputPhotos(ids)
+ if(text) form.promptSections.action=text
+ uploadMessage.value=ids.length
+ ?(text?'Input photos and Used prompt loaded.':'Input photos loaded.')
+ :(text?'Used prompt loaded (no source still on this item).':'Nothing to load from this item.')
+ saveBenchDraft(true)
+}
+function loadInputRaw(item?:any){
+ const asset=item || selected.value
+ if(!asset)return
+ const ids=sourceStillIdsOf(asset)
+ const raw=rawPromptOf(asset)
+ const used=usedPromptOf(asset)
+ if(ids.length) loadInputPhotos(ids)
+ form.promptSections.action=raw || used
+ uploadMessage.value=ids.length
+ ?(raw?'Input photos and Typed prompt loaded.':(used?'Input photos loaded; Typed missing so Used was applied.':'Input photos loaded.'))
+ :(raw?'Typed prompt loaded (no source still).':(used?'Used prompt loaded (no Typed / source still).':'Nothing to load from this item.'))
+ saveBenchDraft(true)
+}
function stillIdSet(){return new Set(stills.value.map((s:any)=>s.id))}
function clipIdSet(){return new Set(clips.value.map((c:any)=>c.id))}
function applyHydratedJob(saved:any,output?:any){
@@ -806,6 +979,7 @@ function applyHydratedJob(saved:any,output?:any){
error.value=missing||''
if(missing)uploadMessage.value=missing
else uploadMessage.value='Job loaded into the bench.'
+ saveBenchDraft(true)
}
function loadSelectedJob(){
if(!selected.value)return
@@ -892,17 +1066,29 @@ function stepFamily(delta:number){
const next=list[(from + delta + list.length) % list.length]
if(next) selectOutput(next)
}
-function onLibraryUseAsPhoto(item:any){
+function onLibraryUseImage(item:any){
if(item)selectOutput(item)
- applyUseAsSource()
+ applyUseImage(item)
+}
+function onLibraryUsePrompt(item:any){
+ if(item)selectOutput(item)
+ applyUsePrompt(item)
}
function onLibraryCopyPrompt(item:any){
if(item)selectOutput(item)
- void copySelectedPrompt()
+ void copySelectedPrompt(item)
}
-function onLibraryLoad(item:any){
+function onLibraryCopyRawPrompt(item:any){
if(item)selectOutput(item)
- loadSelectedJob()
+ void copySelectedRawPrompt(item)
+}
+function onLibraryLoadInput(item:any){
+ if(item)selectOutput(item)
+ loadInputUsed(item)
+}
+function onLibraryLoadInputRaw(item:any){
+ if(item)selectOutput(item)
+ loadInputRaw(item)
}
function onLibraryDelete(item:any){
if(item)selectOutput(item)
@@ -1080,14 +1266,23 @@ async function refresh(){
selected.value=next
}
-watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.imageBId='';form.photos=[];form.startClipId='';loadRevealed(id);if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
-watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
+watch(()=>[
+ form.mode,form.engine,form.folderId,form.enhancePrompt,form.turbo,form.lockFace,form.lockOutfit,
+ form.promptSections,form.photos,form.imageStyles,form.settings,
+ eyeOpen.value,hideInputs.value,
+ music.tags,music.lyrics,music.duration,music.seed,music.name
+],()=>saveBenchDraft(),{deep:true})
let timer:ReturnType,clock:ReturnType,disposed=false
onMounted(async()=>{
let storage;try{storage=localStorage}catch{}
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras(true)
- library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';loadRevealed(form.folderId)
+ library.value=await $fetch('/api/studio-2/library')
+ restoreBenchDraft()
+ if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
+ // Folder watch may have cleared photos when folderId was assigned — re-apply draft photos/prompt.
+ restoreBenchDraft()
+ loadRevealed(form.folderId)
await applyIncomingGenerationLoad()
const id=String(route.query.still||route.query.clip||route.query.track||'')
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
@@ -1098,7 +1293,11 @@ onMounted(async()=>{
})
onBeforeUnmount(()=>{
disposed=true;clearTimeout(timer);clearInterval(clock)
- if(import.meta.client)window.removeEventListener('keydown',onStageHotkey)
+ if(draftTimer)clearTimeout(draftTimer)
+ if(import.meta.client){
+ saveBenchDraft(true)
+ window.removeEventListener('keydown',onStageHotkey)
+ }
})
function randomizeSeed(){form.settings.seedMode='random';form.settings.seed=randomSeed()}
@@ -1128,7 +1327,7 @@ const mobileCtx=computed(()=>({
photoFilledCount:photoFilledCount.value,
openLibrary:()=>{libraryOpen.value=true},
closeLibrary:()=>{libraryOpen.value=false},
- toggleHideInputs:()=>{hideInputs.value=!hideInputs.value},
+ toggleHideInputs:()=>{hideInputs.value=!hideInputs.value;saveBenchDraft(true)},
showInputsAndAddPhoto:()=>{hideInputs.value=false;addPhoto()},
sourceSlot:sourceSlot.value,
video:video.value,
@@ -1168,12 +1367,21 @@ const mobileCtx=computed(()=>({
removePhoto,
setPhotoRole,
mentionPhoto,
- applyUseAsSource,
+ applyUseAsSource:applyUseImage,
+ applyUseImage,
+ applyUsePrompt,
copySelectedPrompt,
+ copySelectedRawPrompt,
+ loadInputUsed,
+ loadInputRaw,
loadSelectedJob,
- onLibraryUseAsPhoto,
+ onLibraryUseAsPhoto:onLibraryUseImage,
+ onLibraryUseImage,
+ onLibraryUsePrompt,
onLibraryCopyPrompt,
- onLibraryLoad,
+ onLibraryCopyRawPrompt,
+ onLibraryLoadInput,
+ onLibraryLoadInputRaw,
onLibraryDelete,
randomizeSeed,
setPicker,