From 7242d0faef80e9ba1bc64dbfc7df3590b07dd82c Mon Sep 17 00:00:00 2001 From: Towsty Date: Mon, 28 Sep 2026 20:11:14 -0500 Subject: [PATCH] Replace the under-stage filmstrip with a Library modal and stage eye. Co-authored-by: Cursor --- STUDIO2-MOBILE-PROMPT.md | 8 +- assets/css/display.css | 86 +++--- components/studio2/Bench.vue | 164 ++++++------ components/studio2/LibraryModal.vue | 398 ++++++++++++++++++++++++++++ components/studio2/Mobile.vue | 166 +++++------- 5 files changed, 601 insertions(+), 221 deletions(-) create mode 100644 components/studio2/LibraryModal.vue diff --git a/STUDIO2-MOBILE-PROMPT.md b/STUDIO2-MOBILE-PROMPT.md index a70b46b..2a0677a 100644 --- a/STUDIO2-MOBILE-PROMPT.md +++ b/STUDIO2-MOBILE-PROMPT.md @@ -30,11 +30,13 @@ Mobile is a **viewer-first phone app** with sheets for the inspector and the que ├───────────────────────────────────────────────────────────┤ │ │ │ STAGE (contain, full remaining height) │ -│ │ -├ filmstrip scroll-x, tiles 72px ───────────────────────────┤ +│ Library (stage chrome) · eye ≥48px top-right │ ├ [Slots] [Prompt] [Look] [Gen] Generate (amber, 48px) ─┤ └───────────────────────────────────────────────────────────┘ -``` + +Library is a **stage button** that opens the Library modal/sheet (same tiles, +filters, Use as photo / Copy / Load / Delete). The under-stage filmstrip row +is revoked — recents live in the modal (newest first).``` Bottom **dock** is 4 tabs that open sheets, plus Generate always visible: diff --git a/assets/css/display.css b/assets/css/display.css index 2f88620..7ae9a5d 100644 --- a/assets/css/display.css +++ b/assets/css/display.css @@ -332,7 +332,7 @@ flex-direction: column; } -/* Stage: fills center column above filmstrip + transport */ +/* Stage: fills center column above transport */ .viewer-frame { position: relative; display: flex; @@ -356,31 +356,65 @@ object-position: center; } -.eye-viewer { +.stage-chrome { position: absolute; top: 10px; right: 10px; + left: 10px; z-index: 5; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + pointer-events: none; +} + +.stage-chrome > * { + pointer-events: auto; +} + +.stage-library { + display: inline-flex; + min-height: 44px; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 8px; + background: #000a; + padding: 0 14px; + color: var(--text); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.stage-library:hover { + border-color: var(--amber); +} + +.stage-library-count { + color: var(--muted); + font-weight: 500; +} + +.eye-viewer { display: grid; - width: 28px; - height: 28px; + width: 44px; + height: 44px; + flex-shrink: 0; place-items: center; border: 1px solid var(--line); - border-radius: 6px; + border-radius: 8px; background: #000a; cursor: pointer; } +.eye-viewer:hover { + border-color: var(--amber); +} + .filmstrip { - display: flex; - flex-shrink: 0; - gap: 8px; - height: 80px; - align-items: center; - overflow-x: auto; - border-top: 1px solid var(--line); - padding: 8px 10px; - background: var(--panel); + display: none; } .tile { @@ -396,7 +430,6 @@ @media (min-width: 1440px) { .tile { width: 120px; height: 80px; } - .filmstrip { height: 96px; } } .tile[data-active='true'] { @@ -438,28 +471,7 @@ .tile .tile-trash { right: 22px; } .strip-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - flex-shrink: 0; - border-top: 1px solid var(--line); - padding: 8px 10px; - background: var(--panel); -} - -.strip-actions button { - border: 1px solid var(--line); - border-radius: 6px; - background: transparent; - color: var(--text); - padding: 4px 10px; - font-size: var(--type-sm); - cursor: pointer; -} - -.strip-actions button:hover { - border-color: var(--amber); - color: var(--amber); + display: none; } .neg-add-row { diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue index ce18fa2..91b279d 100644 --- a/components/studio2/Bench.vue +++ b/components/studio2/Bench.vue @@ -199,16 +199,24 @@
- +
+ + +
-
-
- - - -
-
- -
- - - - -
- -
+
{{ transportLabel }}
@@ -284,6 +252,20 @@
+ + @@ -457,8 +439,8 @@ const library=ref({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref([]) const liveMusic=ref>({}) const negativeDraft=ref('') -const filmstripEl=ref(null) -const tileRefs=new Map() +const libraryOpen=ref(false) +const previewForcedHidden=ref>(new Set()) const form=reactive({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(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('/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}, diff --git a/components/studio2/LibraryModal.vue b/components/studio2/LibraryModal.vue new file mode 100644 index 0000000..c5d8e58 --- /dev/null +++ b/components/studio2/LibraryModal.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/components/studio2/Mobile.vue b/components/studio2/Mobile.vue index 0a10784..9136456 100644 --- a/components/studio2/Mobile.vue +++ b/components/studio2/Mobile.vue @@ -48,15 +48,23 @@
YuE2
- +
+ + +
@@ -73,46 +81,22 @@ playsinline /> Selected still -

Select a still or clip

+

Open Library to pick a still or clip

-
-
- - - -
-
- -
- - - - -
+
@@ -416,56 +400,46 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' }) object-fit: contain; } .m-audio { width: 100%; max-width: 100%; padding: 12px; } -.m-eye { +.m-stage-chrome { position: absolute; top: 8px; right: 8px; + left: 8px; z-index: 5; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + pointer-events: none; +} +.m-stage-chrome > * { pointer-events: auto; } +.m-library { + display: inline-flex; + min-height: 48px; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 8px; + background: #000a; + padding: 0 14px; + color: var(--text); + font: inherit; + font-weight: 600; +} +.m-library-count { + color: var(--muted); + font-weight: 500; +} +.m-eye { display: grid; - width: 28px; - height: 28px; + width: 48px; + height: 48px; + flex-shrink: 0; place-items: center; border: 1px solid var(--line); - border-radius: 6px; + border-radius: 8px; background: #000a; } -.m-strip { - display: flex; - height: 64px; - flex-shrink: 0; - gap: 6px; - overflow-x: auto; - border-top: 1px solid var(--line); - padding: 8px 12px; -} -.m-tile { - position: relative; - width: 72px; - height: 48px; - flex: 0 0 auto; - overflow: hidden; - border: 2px solid transparent; - border-radius: 6px; - background: #222; -} -.m-tile.on { border-color: var(--amber); } -.m-tile-pick { display: block; width: 100%; height: 100%; border: 0; padding: 0; background: transparent; } -.m-tile img { width: 100%; height: 100%; object-fit: cover; } -.m-tile-hid { display: grid; height: 100%; place-items: center; color: var(--muted); font-size: 10px; } -.m-tile-eye, .m-tile-trash { - position: absolute; - top: 2px; - right: 2px; - display: grid; - width: 16px; - height: 16px; - place-items: center; - border: 0; - border-radius: 3px; - background: #000a; - font-size: 10px; -} -.m-tile-trash { right: 20px; } .m-dock { display: grid; grid-template-columns: repeat(4, 1fr) 120px; @@ -564,22 +538,6 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' }) .m-neg-chip button { border: 0; background: transparent; color: inherit; } .m-neg-add { display: flex; gap: 8px; align-items: center; margin-top: 8px; } .m-neg-add .m-input { flex: 1; margin-top: 0; } -.m-strip-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 8px 10px; - border-top: 1px solid var(--line); - background: var(--panel); -} -.m-strip-actions button { - border: 1px solid var(--line); - border-radius: 6px; - background: transparent; - color: var(--text); - padding: 4px 8px; - font-size: 12px; -} .m-seed { display: flex; gap: 8px; margin-top: 4px; } .m-seed .m-input { margin-top: 0; flex: 1; } .m-dice {