Put still actions on the stage and split output vs input hide.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-28 21:26:29 -05:00
co-authored by Cursor
parent f9903ed76e
commit d61f99d6fd
4 changed files with 268 additions and 69 deletions
+72 -31
View File
@@ -79,15 +79,34 @@
<template v-else>
<section v-if="photosPlanState.show" class="photos-section" aria-label="Photos">
<span class="field-label">Photos</span>
<div class="photos-head">
<span class="field-label">Photos</span>
<button
v-if="!photosPlanState.handoffReadonly"
type="button"
class="photos-inputs-toggle"
@click="hideInputs=!hideInputs"
>{{ hideInputs ? 'Show inputs' : 'Hide inputs' }}</button>
</div>
<p v-if="photosPlanState.handoffReadonly" class="text-[var(--type-sm)] text-[var(--muted)]">Continues from last frame</p>
<template v-else-if="hideInputs">
<p class="photos-collapsed">
<span>{{ photoFilledCount }} photo{{ photoFilledCount===1 ? '' : 's' }}</span>
<button
v-if="form.photos.length < photosPlanState.max"
type="button"
class="photos-add-link"
@click="hideInputs=false; addPhoto()"
>+ Add photo</button>
</p>
</template>
<template v-else>
<p v-if="!form.photos.length && photosPlanState.emptyHint" class="text-[var(--type-sm)] text-[var(--muted)]">{{ photosPlanState.emptyHint }}</p>
<Studio2AssetSlot
v-for="(card, index) in form.photos"
:key="'photo-'+index+'-'+card.stillId"
:asset="stillById(card.stillId)"
:hidden="slotHidden(card.stillId)"
:hidden="false"
:role="card.role"
:role-options="photoRoleOptions"
@pick="picker='photo:'+index"
@@ -200,22 +219,34 @@
<div class="center-stack">
<div class="viewer-frame">
<div class="stage-chrome" aria-label="Stage controls">
<button
type="button"
class="stage-library"
:aria-expanded="libraryOpen ? 'true' : 'false'"
@click="libraryOpen=true"
>Library<span v-if="familyStillCount" class="stage-library-count">{{ familyStillCount }}</span></button>
<button
v-if="selected"
type="button"
class="eye-viewer"
:aria-pressed="!isHidden(selected)"
:aria-label="isHidden(selected) ? 'Show preview' : 'Hide preview'"
@click.stop="toggleReveal(selected.id)"
<div class="stage-chrome-top">
<button
type="button"
class="stage-library"
:aria-expanded="libraryOpen ? 'true' : 'false'"
@click="libraryOpen=true"
>Library<span v-if="familyStillCount" class="stage-library-count">{{ familyStillCount }}</span></button>
<button
v-if="selected"
type="button"
class="eye-viewer"
:aria-pressed="eyeOpen"
:aria-label="eyeOpen ? 'Hide output' : 'Show output'"
@click.stop="toggleReveal()"
>
<img :src="eyeOpen ? '/icons/display/eye.svg' : '/icons/display/eye-off.svg'" alt="" width="20" height="20">
</button>
</div>
<div
v-if="selected && selected.kind!=='track'"
class="stage-actions"
aria-label="Selected still actions"
>
<img :src="isHidden(selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="20" height="20">
</button>
<button type="button" @click.stop="applyUseAsSource">Use as photo</button>
<button type="button" @click.stop="copySelectedPrompt">Copy prompt</button>
<button type="button" @click.stop="loadSelectedJob">Load</button>
<button type="button" class="stage-action-danger" @click.stop="deleteOutput">Delete</button>
</div>
</div>
<button
@@ -234,7 +265,7 @@
>›</button>
<template v-if="selected && isHidden(selected)">
<p class="placeholder-still">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : selected.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</p>
<p class="placeholder-still">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}</p>
</template>
<template v-else-if="selected?.kind==='track'">
<div class="w-full max-w-xl px-6 py-4">
@@ -273,13 +304,14 @@
:assets="family"
:selected="selected"
:folder-id="form.folderId"
:is-hidden="isHidden"
:preview-hidden="!eyeOpen"
@close="libraryOpen=false"
@select="selectOutput"
@use-as-photo="onLibraryUseAsPhoto"
@copy-prompt="onLibraryCopyPrompt"
@load="onLibraryLoad"
@delete="onLibraryDelete"
@toggle-preview="toggleReveal()"
/>
</section>
@@ -456,6 +488,7 @@ const liveMusic=ref<Record<string,any>>({})
const negativeDraft=ref('')
const libraryOpen=ref(false)
const eyeOpen=ref(!xaigen)
const hideInputs=ref(!!xaigen)
const form=reactive<any>({mode:'generate',engine:'flux',enhancePrompt:readStoredEnhancePrompt(),turbo:readStoredTurbo(),imageStyles:{positive:[],negative:readStoredNegatives()},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],photos:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
const promptArea=ref<HTMLTextAreaElement|null>(null)
const promptCaret=ref({start:0,end:0})
@@ -554,6 +587,7 @@ const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.valu
const negativeChips=computed(()=>form.imageStyles.negative || [])
const photosPlanState=computed(()=>photosPlan({mode:form.mode,engine:form.engine}))
const photoRoleOptions=computed(()=>(photosPlanState.value.roles||[]).map((id:string)=>({id,label:PHOTO_ROLE_LABEL[id]||id})))
const photoFilledCount=computed(()=>(form.photos||[]).filter((p:any)=>p?.stillId).length)
const photoRoleWarning=computed(()=>{
const dup=duplicatePhotoRole(form.photos)
return dup?`Two photos are set to the same job (${dup}). Change one.`:''
@@ -823,14 +857,24 @@ function pickerThumbHidden(asset:any){
if(xaigen) return previewHidden(asset,revealed.value,xaigen)
return false
}
function slotHidden(id:string){
if(!xaigen || !id)return false
if(isClassic.value)return classicHideInput.value
return !revealed.value.has(id)
function slotHidden(_id:string){
// Photos inspector uses hideInputs — never the output eye.
return hideInputs.value
}
function toggleReveal(_id?:string){
eyeOpen.value=!eyeOpen.value
}
async function deleteOutput(){
if(!selected.value)return
const label=assetLabel(selected.value)
if(import.meta.client && !window.confirm(`Delete ${label}?`))return
try{
const kind=selected.value.kind==='clip'?'clips/':selected.value.kind==='track'?'tracks/':'stills/'
const id=selected.value.id
await $fetch('/api/library/'+kind+id,{method:'DELETE'})
await refresh()
}catch(e:any){error.value=e.data?.statusMessage||e.message}
}
function stepFamily(delta:number){
const list=family.value
if(list.length < 2)return
@@ -970,14 +1014,6 @@ function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMe
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 clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
async function deleteOutput(){
if(!selected.value)return
try{
const kind=selected.value.kind==='clip'?'clips/':selected.value.kind==='track'?'tracks/':'stills/'
await $fetch('/api/library/'+kind+selected.value.id,{method:'DELETE'})
await refresh()
}catch(e:any){error.value=e.data?.statusMessage||e.message}
}
async function queue(){
submitting.value=true;error.value=''
try{
@@ -1078,8 +1114,13 @@ const mobileCtx=computed(()=>({
family:family.value,
familyStillCount:familyStillCount.value,
libraryOpen:libraryOpen.value,
eyeOpen:eyeOpen.value,
hideInputs:hideInputs.value,
photoFilledCount:photoFilledCount.value,
openLibrary:()=>{libraryOpen.value=true},
closeLibrary:()=>{libraryOpen.value=false},
toggleHideInputs:()=>{hideInputs.value=!hideInputs.value},
showInputsAndAddPhoto:()=>{hideInputs.value=false;addPhoto()},
sourceSlot:sourceSlot.value,
video:video.value,
submitting:submitting.value,