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
+82 -4
View File
@@ -363,13 +363,21 @@
left: 10px;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-direction: column;
align-items: stretch;
gap: 8px;
pointer-events: none;
}
.stage-chrome > * {
.stage-chrome-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.stage-chrome-top > *,
.stage-actions {
pointer-events: auto;
}
@@ -397,6 +405,33 @@
font-weight: 500;
}
.stage-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-width: calc(100% - 108px);
}
.stage-actions button {
min-height: 36px;
border: 1px solid var(--line);
border-radius: 8px;
background: #000a;
color: var(--text);
padding: 0 10px;
font: inherit;
font-size: var(--type-sm);
cursor: pointer;
}
.stage-actions button:hover {
border-color: var(--amber);
}
.stage-actions .stage-action-danger {
color: #fca5a5;
}
.eye-viewer {
display: grid;
width: 44px;
@@ -444,6 +479,49 @@
right: 10px;
}
.photos-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.photos-inputs-toggle {
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--muted);
padding: 4px 8px;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.photos-inputs-toggle:hover {
border-color: var(--amber);
color: var(--amber);
}
.photos-collapsed {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: var(--type-sm);
}
.photos-add-link {
border: 0;
background: transparent;
color: var(--amber);
padding: 0;
font: inherit;
font-size: var(--type-sm);
cursor: pointer;
}
.filmstrip {
display: none;
}
+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,
+23
View File
@@ -35,6 +35,11 @@
placeholder="Name or prompt"
aria-label="Search library"
>
<button
type="button"
class="lib-modal-preview-all"
@click="$emit('toggle-preview')"
>{{ previewHidden ? 'Show all previews' : 'Hide all previews' }}</button>
<div class="lib-modal-filters" role="group" aria-label="Type">
<button
v-for="opt in filterOptions"
@@ -95,6 +100,7 @@
<button type="button" @click="$emit('use-as-photo', detailAsset)">Use as photo</button>
<button type="button" @click="$emit('copy-prompt', detailAsset)">Copy prompt</button>
<button type="button" @click="$emit('load', detailAsset)">Load</button>
<button type="button" @click="$emit('toggle-preview')">{{ previewHidden ? 'Show output' : 'Hide output' }}</button>
<button type="button" class="danger" @click="onDeleteClick">Delete</button>
<button type="button" class="primary" @click="useAndClose">Use and close</button>
</div>
@@ -126,6 +132,7 @@ const props = defineProps<{
assets?: any[]
selected?: any
folderId?: string
previewHidden?: boolean
isHidden?: (asset: any) => boolean
}>()
@@ -136,6 +143,7 @@ const emit = defineEmits<{
'copy-prompt': [asset: any]
load: [asset: any]
delete: [asset: any]
'toggle-preview': []
}>()
const filterOptions = [
@@ -402,6 +410,21 @@ onBeforeUnmount(() => {
color: inherit;
font: inherit;
}
.lib-modal-preview-all {
border: 1px solid #2a2a2e;
border-radius: 8px;
background: transparent;
color: inherit;
padding: 8px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.lib-modal-preview-all:hover {
border-color: #f5a524;
color: #f5a524;
}
.lib-modal-filters { display: inline-flex; gap: 4px; }
.lib-modal-chip {
border: 1px solid #2a2a2e;
+91 -34
View File
@@ -49,21 +49,33 @@
<div class="m-stage">
<div class="m-stage-chrome" aria-label="Stage controls">
<button
type="button"
class="m-library"
:aria-expanded="ctx.libraryOpen ? 'true' : 'false'"
@click="ctx.openLibrary()"
>Library<span v-if="ctx.familyStillCount" class="m-library-count">{{ ctx.familyStillCount }}</span></button>
<button
v-if="ctx.selected"
type="button"
class="m-eye"
:aria-label="ctx.isHidden(ctx.selected) ? 'Show preview' : 'Hide preview'"
@click.stop="ctx.toggleReveal(ctx.selected.id)"
<div class="m-stage-chrome-top">
<button
type="button"
class="m-library"
:aria-expanded="ctx.libraryOpen ? 'true' : 'false'"
@click="ctx.openLibrary()"
>Library<span v-if="ctx.familyStillCount" class="m-library-count">{{ ctx.familyStillCount }}</span></button>
<button
v-if="ctx.selected"
type="button"
class="m-eye"
:aria-label="ctx.eyeOpen ? 'Hide output' : 'Show output'"
@click.stop="ctx.toggleReveal()"
>
<img :src="ctx.eyeOpen ? '/icons/display/eye.svg' : '/icons/display/eye-off.svg'" alt="" width="20" height="20">
</button>
</div>
<div
v-if="ctx.selected && ctx.selected.kind!=='track'"
class="m-stage-actions"
aria-label="Selected still actions"
>
<img :src="ctx.isHidden(ctx.selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="20" height="20">
</button>
<button type="button" @click.stop="ctx.applyUseAsSource">Use as photo</button>
<button type="button" @click.stop="ctx.copySelectedPrompt">Copy prompt</button>
<button type="button" @click.stop="ctx.loadSelectedJob">Load</button>
<button type="button" class="m-danger" @click.stop="ctx.deleteOutput">Delete</button>
</div>
</div>
<button
v-if="(ctx.family || []).length > 1"
@@ -80,7 +92,7 @@
@click.stop="ctx.stepFamily(1)"
>›</button>
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
<p class="m-muted">{{ ctx.selected.kind==='track' ? 'Track loaded · preview hidden' : 'Preview hidden' }}</p>
<p class="m-muted">{{ ctx.selected.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}</p>
</template>
<template v-else-if="ctx.selected?.kind==='track'">
<div class="m-audio">
@@ -103,13 +115,14 @@
:assets="ctx.family || []"
:selected="ctx.selected"
:folder-id="ctx.form?.folderId"
:is-hidden="ctx.isHidden"
:preview-hidden="!ctx.eyeOpen"
@close="ctx.closeLibrary()"
@select="ctx.selectOutput"
@use-as-photo="ctx.onLibraryUseAsPhoto"
@copy-prompt="ctx.onLibraryCopyPrompt"
@load="ctx.onLibraryLoad"
@delete="ctx.onLibraryDelete"
@toggle-preview="ctx.toggleReveal()"
/>
<div class="m-dock">
@@ -133,21 +146,39 @@
<p class="m-muted">Continues from last frame</p>
</template>
<template v-else>
<p v-if="!ctx.form.photos?.length && ctx.photosPlanState.emptyHint" class="m-muted">{{ ctx.photosPlanState.emptyHint }}</p>
<Studio2AssetSlot
v-for="(card, index) in ctx.form.photos"
:key="'m-photo-'+index+'-'+card.stillId"
:asset="ctx.stillById(card.stillId)"
:hidden="ctx.slotHidden(card.stillId)"
:role="card.role"
:role-options="ctx.photoRoleOptions"
@pick="ctx.setPicker('photo:'+index)"
@remove="ctx.removePhoto(index)"
@mention="ctx.mentionPhoto(index)"
@update:role="ctx.setPhotoRole(index, $event)"
/>
<button v-if="(ctx.form.photos?.length||0) < ctx.photosPlanState.max" type="button" class="m-amber" @click="ctx.addPhoto">+ Add photo</button>
<p v-if="ctx.photoRoleWarning" class="m-hint">{{ ctx.photoRoleWarning }}</p>
<div class="m-photos-head">
<button type="button" class="m-link" @click="ctx.toggleHideInputs()">
{{ ctx.hideInputs ? 'Show inputs' : 'Hide inputs' }}
</button>
</div>
<template v-if="ctx.hideInputs">
<p class="m-muted">
{{ ctx.photoFilledCount || 0 }} photo{{ (ctx.photoFilledCount||0)===1 ? '' : 's' }}
<button
v-if="(ctx.form.photos?.length||0) < ctx.photosPlanState.max"
type="button"
class="m-amber"
@click="ctx.showInputsAndAddPhoto()"
>+ Add photo</button>
</p>
</template>
<template v-else>
<p v-if="!ctx.form.photos?.length && ctx.photosPlanState.emptyHint" class="m-muted">{{ ctx.photosPlanState.emptyHint }}</p>
<Studio2AssetSlot
v-for="(card, index) in ctx.form.photos"
:key="'m-photo-'+index+'-'+card.stillId"
:asset="ctx.stillById(card.stillId)"
:hidden="false"
:role="card.role"
:role-options="ctx.photoRoleOptions"
@pick="ctx.setPicker('photo:'+index)"
@remove="ctx.removePhoto(index)"
@mention="ctx.mentionPhoto(index)"
@update:role="ctx.setPhotoRole(index, $event)"
/>
<button v-if="(ctx.form.photos?.length||0) < ctx.photosPlanState.max" type="button" class="m-amber" @click="ctx.addPhoto">+ Add photo</button>
<p v-if="ctx.photoRoleWarning" class="m-hint">{{ ctx.photoRoleWarning }}</p>
</template>
<template v-if="ctx.showIdentityLocks">
<label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label>
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label>
@@ -421,12 +452,20 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
left: 8px;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.m-stage-chrome > * { pointer-events: auto; }
.m-stage-chrome-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.m-stage-chrome-top > *,
.m-stage-actions {
pointer-events: auto;
}
.m-library {
display: inline-flex;
min-height: 48px;
@@ -454,6 +493,23 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
border-radius: 8px;
background: #000a;
}
.m-stage-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-width: calc(100% - 56px);
}
.m-stage-actions button {
min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
background: #000a;
color: var(--text);
padding: 0 10px;
font: inherit;
font-size: 12px;
}
.m-stage-actions .m-danger { color: #fca5a5; }
.m-nav {
position: absolute;
top: 50%;
@@ -472,6 +528,7 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
}
.m-nav-prev { left: 8px; }
.m-nav-next { right: 8px; }
.m-photos-head { margin-bottom: 8px; }
.m-dock {
display: grid;
grid-template-columns: repeat(4, 1fr) 120px;