Compare commits

...
2 Commits
Author SHA1 Message Date
TowstyandCursor 5b0d7370ed Show Typed and Used prompt fields on still Details.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 20:11:18 -05:00
TowstyandCursor 7242d0faef Replace the under-stage filmstrip with a Library modal and stage eye.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 20:11:14 -05:00
7 changed files with 621 additions and 225 deletions
+5 -3
View File
@@ -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:
+49 -37
View File
@@ -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 {
+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},
+13 -2
View File
@@ -5,6 +5,18 @@
</template>
<script setup lang="ts">
const props=defineProps<{metadata?:any}>()
function promptRows(m:any){
const used=String(m.prompt || m.compiledPrompt || '').trim()
const typed=String(m.promptRaw || '').trim()
if(typed && typed!==used){
return [
{label:'Typed',value:typed},
...(used?[{label:'Used',value:used}]:[])
]
}
if(used) return [{label:'Prompt',value:used}]
return []
}
const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=(v:any)=>v?new Date(v).toLocaleString():null;return [
{label:'Kind / mode',value:[m.kind,m.mode].filter(Boolean).join(' / ')},
{label:'Engine',value:m.engine},{label:'Graph ID',value:m.graphId},
@@ -19,8 +31,7 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
{label:'Prompt',value:m.prompt || m.compiledPrompt},
{label:'Typed',value:m.promptRaw || null},
...promptRows(m),
{label:'Task',value:m.task || m.mode || null},
{label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'},
{label:'Enhance',value:m.enhance?.refused?'Enhance skipped (model refused) — used your prompt.':m.enhance?.skippedAsDescribe?'Enhance skipped (describe rewrite) — keep stanza + typed only.':m.enhance?.used?'Applied':m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Applied':'Parse incomplete'},
+398
View File
@@ -0,0 +1,398 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="lib-modal-root"
role="presentation"
@keydown.esc.prevent="close"
>
<div class="lib-modal-backdrop" @click="close" />
<section
ref="dialogEl"
class="lib-modal"
role="dialog"
aria-modal="true"
aria-labelledby="lib-modal-title"
tabindex="-1"
@keydown.tab="onTab"
>
<header class="lib-modal-head">
<h2 id="lib-modal-title">Library</h2>
<div class="lib-modal-head-actions">
<NuxtLink class="lib-modal-full" :to="{ path: '/library', query: folderId ? { folder: folderId } : {} }" @click="close">Full library</NuxtLink>
<button type="button" class="lib-modal-x" aria-label="Close" @click="close">×</button>
</div>
</header>
<div class="lib-modal-toolbar">
<input
v-model="query"
type="search"
class="lib-modal-search"
placeholder="Name or prompt"
aria-label="Search library"
>
<div class="lib-modal-filters" role="group" aria-label="Type">
<button
v-for="opt in typeOptions"
:key="opt.id"
type="button"
class="lib-modal-chip"
:data-on="filter === opt.id ? 'true' : undefined"
@click="filter = opt.id"
>{{ opt.label }}</button>
</div>
</div>
<div class="lib-modal-body">
<div class="lib-modal-grid" aria-label="Folder assets">
<button
v-for="item in filtered"
:key="item.id"
type="button"
class="lib-modal-tile"
:data-active="selectedId === item.id ? 'true' : undefined"
@click="$emit('select', item)"
>
<span v-if="isHidden?.(item)" class="lib-modal-hid">Hidden</span>
<span v-else-if="item.kind === 'track'" class="lib-modal-hid">Audio</span>
<img
v-else
:src="item.kind === 'clip' ? `/api/library/clips/${item.id}/thumb` : stillUrl(item.id)"
alt=""
>
<span class="lib-modal-caption">{{ label(item) }}</span>
</button>
<p v-if="!filtered.length" class="lib-modal-empty">No items in this folder yet.</p>
</div>
<aside v-if="selected" class="lib-modal-detail">
<p class="lib-modal-detail-name">{{ label(selected) }}</p>
<div v-if="selected.kind !== 'track'" class="lib-modal-actions">
<button type="button" @click="$emit('use-as-photo', selected)">Use as photo</button>
<button type="button" @click="$emit('copy-prompt', selected)">Copy prompt</button>
<button type="button" @click="$emit('load', selected)">Load</button>
<button type="button" class="danger" @click="$emit('delete', selected)">Delete</button>
<button type="button" class="primary" @click="useAndClose">Use and close</button>
</div>
<LibrarySettingsDetails :rows="detailRows" empty="No generator settings were saved with this item." />
</aside>
</div>
</section>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration'
import { assetLabel } from '~/shared/studio2/viewer.mjs'
const props = defineProps<{
open: boolean
assets: any[]
selected?: any
folderId?: string
isHidden?: (asset: any) => boolean
}>()
const emit = defineEmits<{
close: []
select: [asset: any]
'use-as-photo': [asset: any]
'copy-prompt': [asset: any]
load: [asset: any]
delete: [asset: any]
}>()
const query = ref('')
const filter = ref<'all' | 'images' | 'videos' | 'music'>('all')
const dialogEl = ref<HTMLElement | null>(null)
const typeOptions = [
{ id: 'all' as const, label: 'All' },
{ id: 'images' as const, label: 'Images' },
{ id: 'videos' as const, label: 'Videos' },
{ id: 'music' as const, label: 'Music' }
]
const selectedId = computed(() => props.selected?.id || '')
const stillUrl = (id: string) => `/api/library/stills/${id}`
const label = (item: any) => assetLabel(item)
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
let list = [...(props.assets || [])]
if (filter.value === 'images') list = list.filter(a => a.kind === 'still')
if (filter.value === 'videos') list = list.filter(a => a.kind === 'clip')
if (filter.value === 'music') list = list.filter(a => a.kind === 'track')
if (q) {
list = list.filter(a => {
const hay = [
a.name, a.filename, a.prompt, a.promptRaw,
a.studio2?.prompt, a.studio2?.promptRaw, a.studio2?.compiledPrompt,
a.studio2?.promptSections?.action
].filter(Boolean).join(' ').toLowerCase()
return hay.includes(q)
})
}
// Newest first
return list.sort((a, b) => (b.createdAt || b.studio2?.savedAt || 0) - (a.createdAt || a.studio2?.savedAt || 0))
})
const detailRows = computed(() => {
const item = props.selected
if (!item) return []
if (item.kind === 'clip') return clipSettingsRows(item)
if (item.kind === 'track') return trackSettingsRows(item)
return stillSettingsRows(item)
})
function close() { emit('close') }
function useAndClose() {
if (props.selected) emit('select', props.selected)
close()
}
function onTab(e: KeyboardEvent) {
const root = dialogEl.value
if (!root) return
const focusable = [...root.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)].filter(el => el.offsetParent !== null || el === document.activeElement)
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
watch(() => props.open, (on) => {
if (!import.meta.client) return
if (on) {
document.body.style.overflow = 'hidden'
nextTick(() => dialogEl.value?.focus())
} else {
document.body.style.overflow = ''
}
})
onBeforeUnmount(() => {
if (import.meta.client) document.body.style.overflow = ''
})
</script>
<style scoped>
.lib-modal-root {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: stretch;
justify-content: center;
}
.lib-modal-backdrop {
position: absolute;
inset: 0;
background: #000a;
}
.lib-modal {
position: relative;
z-index: 1;
display: flex;
width: min(1100px, 100%);
max-height: min(92vh, 900px);
margin: auto;
flex-direction: column;
overflow: hidden;
border: 1px solid #2a2a2e;
border-radius: 12px;
background: #141416;
color: #f3f1ea;
outline: none;
}
@media (max-width: 767px) {
.lib-modal-root { align-items: flex-end; }
.lib-modal {
width: 100%;
max-height: 100dvh;
height: 100dvh;
margin: 0;
border-radius: 16px 16px 0 0;
}
}
.lib-modal-head {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #2a2a2e;
padding: 12px 16px;
}
.lib-modal-head h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.lib-modal-head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.lib-modal-full {
color: #f5a524;
font-size: 0.85rem;
text-decoration: none;
}
.lib-modal-x {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 1px solid #2a2a2e;
border-radius: 8px;
background: transparent;
color: inherit;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
.lib-modal-toolbar {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 10px;
align-items: center;
border-bottom: 1px solid #2a2a2e;
padding: 10px 16px;
}
.lib-modal-search {
min-width: 12rem;
flex: 1;
border: 1px solid #2a2a2e;
border-radius: 8px;
background: #0b0b0d;
padding: 8px 10px;
color: inherit;
font: inherit;
}
.lib-modal-filters { display: inline-flex; gap: 4px; }
.lib-modal-chip {
border: 1px solid #2a2a2e;
border-radius: 999px;
background: transparent;
color: #9a9588;
padding: 6px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.lib-modal-chip[data-on='true'] {
border-color: #f5a524;
background: #f5a52422;
color: #f5a524;
}
.lib-modal-body {
display: flex;
min-height: 0;
flex: 1;
gap: 0;
}
@media (max-width: 767px) {
.lib-modal-body { flex-direction: column; }
}
.lib-modal-grid {
display: grid;
flex: 1;
min-width: 0;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
align-content: start;
overflow: auto;
padding: 14px 16px;
}
.lib-modal-tile {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border: 2px solid transparent;
border-radius: 8px;
background: #222;
padding: 0;
color: inherit;
text-align: left;
cursor: pointer;
}
.lib-modal-tile[data-active='true'] { border-color: #f5a524; }
.lib-modal-tile img {
aspect-ratio: 4 / 3;
width: 100%;
object-fit: cover;
background: #111;
}
.lib-modal-hid {
display: grid;
aspect-ratio: 4 / 3;
place-items: center;
color: #9a9588;
font-size: 12px;
}
.lib-modal-caption {
padding: 6px 8px;
font-size: 11px;
color: #9a9588;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lib-modal-empty {
grid-column: 1 / -1;
color: #9a9588;
font-size: 0.9rem;
}
.lib-modal-detail {
flex: 0 0 min(320px, 38%);
min-width: 0;
overflow: auto;
border-left: 1px solid #2a2a2e;
padding: 14px 16px;
}
@media (max-width: 767px) {
.lib-modal-detail {
flex: 0 0 auto;
max-height: 40%;
border-left: 0;
border-top: 1px solid #2a2a2e;
}
}
.lib-modal-detail-name {
margin: 0 0 10px;
font-weight: 600;
}
.lib-modal-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.lib-modal-actions button {
border: 1px solid #2a2a2e;
border-radius: 6px;
background: transparent;
color: inherit;
padding: 6px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.lib-modal-actions button.primary {
border-color: #f5a524;
background: #f5a524;
color: #1a1204;
font-weight: 700;
}
.lib-modal-actions button.danger { color: #fca5a5; }
</style>
+62 -104
View File
@@ -48,15 +48,23 @@
<div v-else class="m-chips"><span class="m-chip on">YuE2</span></div>
<div class="m-stage">
<button
v-if="ctx.selected && ctx.xaigen"
type="button"
class="m-eye"
:aria-label="ctx.isHidden(ctx.selected) ? 'Show preview' : 'Hide preview'"
@click.stop="ctx.toggleReveal(ctx.selected.id)"
>
<img :src="ctx.isHidden(ctx.selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
</button>
<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)"
>
<img :src="ctx.isHidden(ctx.selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="20" height="20">
</button>
</div>
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
<p class="m-muted">{{ ctx.selected.kind==='track' ? 'Track loaded · preview hidden' : 'Preview hidden' }}</p>
</template>
@@ -73,46 +81,22 @@
playsinline
/>
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still">
<p v-else class="m-muted">Select a still or clip</p>
<p v-else class="m-muted">Open Library to pick a still or clip</p>
</div>
<div class="m-strip" aria-label="Family outputs">
<div
v-for="o in ctx.family"
:key="o.id"
class="m-tile"
:class="{ on: ctx.selected?.id===o.id }"
>
<button type="button" class="m-tile-pick" @click="ctx.selectOutput(o)">
<span v-if="ctx.isHidden(o)" class="m-tile-hid">Hidden</span>
<span v-else-if="o.kind==='track'" class="m-tile-hid">Audio</span>
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : ctx.stillUrl(o.id)" alt="">
</button>
<button
v-if="ctx.xaigen"
type="button"
class="m-tile-eye"
:aria-label="ctx.isHidden(o) ? 'Show tile' : 'Hide tile'"
@click.stop="ctx.toggleReveal(o.id)"
>
<img :src="ctx.isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
</button>
<button
v-if="ctx.selected?.id===o.id"
type="button"
class="m-tile-trash"
aria-label="Delete selected"
@click.stop="ctx.deleteOutput"
>🗑</button>
</div>
</div>
<div v-if="ctx.selected && ctx.selected.kind!=='track'" class="m-strip-actions">
<button type="button" @click="ctx.applyUseAsSource">Use as photo</button>
<button type="button" @click="ctx.copySelectedPrompt">Copy prompt</button>
<button type="button" @click="ctx.loadSelectedJob">Load</button>
<button type="button" @click="ctx.deleteOutput">Delete</button>
</div>
<Studio2LibraryModal
:open="!!ctx.libraryOpen"
:assets="ctx.family || []"
:selected="ctx.selected"
:folder-id="ctx.form?.folderId"
:is-hidden="ctx.isHidden"
@close="ctx.closeLibrary()"
@select="ctx.selectOutput"
@use-as-photo="ctx.onLibraryUseAsPhoto"
@copy-prompt="ctx.onLibraryCopyPrompt"
@load="ctx.onLibraryLoad"
@delete="ctx.onLibraryDelete"
/>
<div class="m-dock">
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Photos</button>
@@ -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 {
+7 -2
View File
@@ -62,8 +62,13 @@ function pushPromptRows(rows: SettingsRow[], item: {
post: item.promptPost
})
if (parts.pre.trim()) rows.push({ label: 'Pre-text', value: parts.pre.trim() })
if (parts.prompt.trim()) rows.push({ label: 'Prompt', value: parts.prompt.trim() })
if (typed && typed !== parts.prompt.trim()) rows.push({ label: 'Typed', value: typed })
const used = parts.prompt.trim()
if (typed && typed !== used) {
rows.push({ label: 'Typed', value: typed })
if (used) rows.push({ label: 'Used', value: used })
} else if (used) {
rows.push({ label: 'Prompt', value: used })
}
if (parts.post.trim()) rows.push({ label: 'Post-text', value: parts.post.trim() })
}