Replace the under-stage filmstrip with a Library modal and stage eye.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-28 20:11:14 -05:00
co-authored by Cursor
parent e06afe4a17
commit 7242d0faef
5 changed files with 601 additions and 221 deletions
+87 -77
View File
@@ -199,16 +199,24 @@
<div class="center-stack">
<div class="viewer-frame">
<button
v-if="selected && xaigen"
type="button"
class="eye-viewer"
:aria-pressed="!isHidden(selected)"
:aria-label="isHidden(selected) ? 'Show preview' : 'Hide preview'"
@click.stop="toggleReveal(selected.id)"
>
<img :src="isHidden(selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
</button>
<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)"
>
<img :src="isHidden(selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="20" height="20">
</button>
</div>
<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>
@@ -232,51 +240,11 @@
<img :src="stillUrl(selected.id)" alt="Selected still">
</template>
<template v-else>
<p class="placeholder-still">Select a still or clip in the strip</p>
<p class="placeholder-still">Open Library to pick a still or clip</p>
</template>
</div>
<div class="filmstrip" ref="filmstripEl" aria-label="Current family outputs">
<div
v-for="o in family"
:key="o.id"
class="tile"
:data-active="selected?.id===o.id ? 'true' : undefined"
:ref="el => setTileRef(o.id, el)"
>
<button type="button" class="pick" @click="selectOutput(o)">
<span v-if="isHidden(o)" class="flex h-full items-center justify-center p-1 text-[10px] text-[var(--muted)]">Hidden</span>
<span v-else-if="o.kind==='track'" class="flex h-full items-center justify-center text-[10px] text-[var(--amber)]">Audio</span>
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : stillUrl(o.id)" alt="">
</button>
<button
v-if="xaigen"
type="button"
class="eye-tile"
:aria-label="isHidden(o) ? 'Show tile' : 'Hide tile'"
@click.stop="toggleReveal(o.id)"
>
<img :src="isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
</button>
<button
v-if="selected?.id===o.id"
type="button"
class="tile-trash"
aria-label="Delete selected"
title="Delete"
@click.stop="deleteOutput"
>🗑</button>
</div>
</div>
<div v-if="selected && selected.kind!=='track'" class="strip-actions" aria-label="Selected still actions">
<button type="button" @click="applyUseAsSource">Use as photo</button>
<button type="button" @click="copySelectedPrompt">Copy prompt</button>
<button type="button" @click="loadSelectedJob">Load</button>
<button type="button" @click="deleteOutput">Delete</button>
</div>
<div class="transport">
<div v-if="selected?.kind==='clip'" class="transport">
<span>{{ transportLabel }}</span>
<div class="ops">
<button type="button" :aria-pressed="viewerMuted" :aria-label="viewerMuted ? 'Unmute' : 'Mute'" @click="viewerMuted=!viewerMuted">{{ viewerMuted ? '🔇' : '🔊' }}</button>
@@ -284,6 +252,20 @@
</div>
</div>
</div>
<Studio2LibraryModal
:open="libraryOpen"
:assets="family"
:selected="selected"
:folder-id="form.folderId"
:is-hidden="isHidden"
@close="libraryOpen=false"
@select="selectOutput"
@use-as-photo="onLibraryUseAsPhoto"
@copy-prompt="onLibraryCopyPrompt"
@load="onLibraryLoad"
@delete="onLibraryDelete"
/>
</section>
<!-- RIGHT queue -->
@@ -457,8 +439,8 @@ const library=ref<any>({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref<any[]
const studioQueue=ref<any[]>([])
const liveMusic=ref<Record<string,any>>({})
const negativeDraft=ref('')
const filmstripEl=ref<HTMLElement|null>(null)
const tileRefs=new Map<string,HTMLElement>()
const libraryOpen=ref(false)
const previewForcedHidden=ref<Set<string>>(new Set())
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})
@@ -550,6 +532,7 @@ const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId))
const familyStillCount=computed(()=>family.value.filter((a:any)=>a.kind==='still').length)
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId||form.photos?.[0]?.stillId)))
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine))
@@ -653,19 +636,6 @@ function addNegative(){
}
negativeDraft.value=''
}
function setTileRef(id:string,el:any){
if(el)tileRefs.set(id,el as HTMLElement)
else tileRefs.delete(id)
}
function scrollFilmstripToNewest(){
nextTick(()=>{
const id=selected.value?.id || family.value.at(-1)?.id
const tile=id?tileRefs.get(id):null
const strip=filmstripEl.value
if(tile?.scrollIntoView)tile.scrollIntoView({inline:'end',block:'nearest',behavior:'smooth'})
else if(strip)strip.scrollLeft=strip.scrollWidth
})
}
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(){
@@ -817,19 +787,19 @@ async function applyIncomingGenerationLoad(){
const outId=outputs.at(-1)?.id
const output=outId?family.value.find((a:any)=>a.id===outId):null
applyHydratedJob(request,output||undefined)
scrollFilmstripToNewest()
}
function focusQueueCard(card:any){
focusedJobId.value=card.id
if(card.job?.outputs?.length)focusJob(card.job)
}
function isHidden(asset:any){
if(!xaigen || !asset?.id)return false
if(!asset?.id)return false
if(isClassic.value){
const input=asset.role==='input'
return input?classicHideInput.value:classicHideOutput.value
}
return previewHidden(asset,revealed.value,xaigen)
if(xaigen) return previewHidden(asset,revealed.value,xaigen)
return previewForcedHidden.value.has(asset.id)
}
function pickerThumbHidden(asset:any){
if(pickerRevealAll.value)return false
@@ -842,10 +812,41 @@ function slotHidden(id:string){
}
function toggleReveal(id:string){
if(!id)return
const next=new Set(revealed.value)
if(xaigen){
const next=new Set(revealed.value)
if(next.has(id))next.delete(id);else next.add(id)
revealed.value=next
if(import.meta.client)localStorage.setItem(`studio2-revealed:${form.folderId}`,JSON.stringify([...next]))
return
}
const next=new Set(previewForcedHidden.value)
if(next.has(id))next.delete(id);else next.add(id)
revealed.value=next
if(import.meta.client)localStorage.setItem(`studio2-revealed:${form.folderId}`,JSON.stringify([...next]))
previewForcedHidden.value=next
}
function onLibraryUseAsPhoto(item:any){
if(item)selectOutput(item)
applyUseAsSource()
}
function onLibraryCopyPrompt(item:any){
if(item)selectOutput(item)
void copySelectedPrompt()
}
function onLibraryLoad(item:any){
if(item)selectOutput(item)
loadSelectedJob()
}
function onLibraryDelete(item:any){
if(item)selectOutput(item)
void deleteOutput()
}
function onLibraryHotkey(e:KeyboardEvent){
if(e.key!=='l' && e.key!=='L')return
if(e.metaKey||e.ctrlKey||e.altKey)return
const t=e.target as HTMLElement|null
if(t && (t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable))return
if(libraryOpen.value||picker.value)return
e.preventDefault()
libraryOpen.value=true
}
function closePicker(){
const key=picker.value
@@ -1005,8 +1006,6 @@ async function refresh(){
let next=existingFresh || reconcileSelection(selected.value,family.value)
if(form.mode!=='music' && next?.kind==='track') next=family.value.find((a:any)=>a.kind!=='track') || null
selected.value=next
if(fresh || existingFresh) scrollFilmstripToNewest()
else if(selected.value?.id===family.value.at(-1)?.id) scrollFilmstripToNewest()
}
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'})
@@ -1023,9 +1022,12 @@ onMounted(async()=>{
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
if(route.query.task==='music')form.mode='music'
const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()
scrollFilmstripToNewest()
if(import.meta.client)window.addEventListener('keydown',onLibraryHotkey)
})
onBeforeUnmount(()=>{
disposed=true;clearTimeout(timer);clearInterval(clock)
if(import.meta.client)window.removeEventListener('keydown',onLibraryHotkey)
})
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
function randomizeSeed(){form.settings.seedMode='random';form.settings.seed=randomSeed()}
function setPicker(key:string){picker.value=key}
@@ -1047,6 +1049,10 @@ const mobileCtx=computed(()=>({
canClearFinished:canClearFinished.value,
selected:selected.value,
family:family.value,
familyStillCount:familyStillCount.value,
libraryOpen:libraryOpen.value,
openLibrary:()=>{libraryOpen.value=true},
closeLibrary:()=>{libraryOpen.value=false},
sourceSlot:sourceSlot.value,
video:video.value,
submitting:submitting.value,
@@ -1087,6 +1093,10 @@ const mobileCtx=computed(()=>({
applyUseAsSource,
copySelectedPrompt,
loadSelectedJob,
onLibraryUseAsPhoto,
onLibraryCopyPrompt,
onLibraryLoad,
onLibraryDelete,
randomizeSeed,
setPicker,
setBatchText:(v:string)=>{batchText.value=v},