|
|
|
@@ -356,10 +356,12 @@
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<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">
|
|
|
|
|
<div class="mb-4 flex justify-between"><h2>Choose a photo</h2><button type="button" @click="closePicker">Close</button></div>
|
|
|
|
|
<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">Cancel</button></div>
|
|
|
|
|
<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
|
|
|
|
|
v-if="xaigen && pickerAssets.length"
|
|
|
|
|
type="button"
|
|
|
|
@@ -376,6 +378,7 @@
|
|
|
|
|
<span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</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>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
@@ -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<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){
|
|
|
|
|
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<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(){
|
|
|
|
|
const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([
|
|
|
|
|