diff --git a/assets/css/main.css b/assets/css/main.css
index 081b331..b35eaf3 100644
--- a/assets/css/main.css
+++ b/assets/css/main.css
@@ -47,31 +47,22 @@ textarea {
}
.glass {
- background: rgba(24, 24, 27, 0.72);
+ background: rgba(24, 24, 27, 0.88);
border: 1px solid rgba(255, 255, 255, 0.06);
- box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
- backdrop-filter: blur(18px);
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
+}
+
+@media (hover: hover) and (min-width: 768px) {
+ .glass {
+ background: rgba(24, 24, 27, 0.72);
+ backdrop-filter: blur(8px);
+ }
}
.progress-glow {
box-shadow: 0 0 24px rgba(251, 191, 36, 0.35);
}
-@keyframes sensitive-pulse {
- 0%, 100% {
- box-shadow:
- 0 0 28px rgba(251, 191, 36, 0.25),
- 0 0 90px rgba(251, 191, 36, 0.18),
- inset 0 0 60px rgba(251, 191, 36, 0.08);
- }
- 50% {
- box-shadow:
- 0 0 48px rgba(251, 191, 36, 0.55),
- 0 0 140px rgba(244, 114, 182, 0.28),
- inset 0 0 80px rgba(251, 191, 36, 0.16);
- }
-}
-
.aigen-slider {
-webkit-appearance: none;
appearance: none;
@@ -109,7 +100,6 @@ textarea {
}
.sensitive-glow {
- animation: sensitive-pulse 2.2s ease-in-out infinite;
background:
radial-gradient(circle at 50% 45%, rgba(251, 191, 36, 0.22), transparent 42%),
radial-gradient(circle at 50% 55%, rgba(244, 114, 182, 0.16), transparent 55%),
@@ -120,3 +110,40 @@ textarea {
content-visibility: auto;
contain-intrinsic-size: auto 15rem;
}
+
+.studio-library-card {
+ content-visibility: auto;
+ contain-intrinsic-size: auto 5.5rem;
+}
+
+.family-nav {
+ display: flex;
+ height: 2.75rem;
+ width: 2.75rem;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ background: rgba(9, 9, 11, 0.78);
+ color: #fde68a;
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
+}
+
+.family-nav:hover {
+ border-color: rgba(252, 211, 77, 0.55);
+ color: #fffbeb;
+}
+
+.family-nav-prev {
+ position: absolute;
+ left: 0.5rem;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.family-nav-next {
+ position: absolute;
+ right: 0.5rem;
+ top: 50%;
+ transform: translateY(-50%);
+}
diff --git a/components/AppHeader.vue b/components/AppHeader.vue
index fc279c3..43c7502 100644
--- a/components/AppHeader.vue
+++ b/components/AppHeader.vue
@@ -1,5 +1,5 @@
-
@@ -1924,12 +1934,19 @@
aria-label="Edited still fullscreen"
@click.self="closeEditLightbox"
>
-
+
+
![Edited still]()
+
+
@@ -2613,7 +2630,15 @@ let videoSource: EventSource | null = null
let editSource: EventSource | null = null
let videoPollTimer: ReturnType | null = null
let editPollTimer: ReturnType | null = null
-let healthTimer: ReturnType | null = null
+let healthTimer: ReturnType | null = null
+let studioPollAlive = false
+let loraPollAt = 0
+let concealPointers = 0
+let concealPinch = false
+let concealGestureAt = 0
+let concealStart: { pointerId: number; x: number; y: number; moved: boolean } | null = null
+const CONCEAL_MOVE_PX = 12
+const CONCEAL_GESTURE_MS = 500
let videoSettledUi = false
let editSettledUi = false
let videoListenGen = 0
@@ -3077,6 +3102,30 @@ const reviewingStillId = computed(() => {
if (outputPreviewKind.value !== 'edit' || editLockedSave.value) return ''
return stillIdFromLibraryUrl(editResultUrl.value)
})
+const STUDIO_LIBRARY_CAP = 36
+const visibleLibraryEntries = computed(() => libraryEntries.value.slice(0, STUDIO_LIBRARY_CAP))
+const libraryCapped = computed(() => libraryEntries.value.length > STUDIO_LIBRARY_CAP)
+const outputFamilyStills = computed(() => {
+ const id = reviewingStillId.value
+ if (!id) return [] as LibraryStill[]
+ const group = stillGroups.value.find(entry => entry.parts.some(part => part.id === id))
+ let ordered = chronologicalStills(group?.parts || [])
+ if (ordered.length < 2) {
+ const current = stills.value.find(still => still.id === id)
+ if (current?.familyId) {
+ ordered = chronologicalStills(stills.value.filter(still => still.familyId === current.familyId && still.role !== 'input'))
+ } else if (current) {
+ ordered = [current]
+ }
+ }
+ return ordered.length > 1 ? ordered : []
+})
+const outputFamilyIndex = computed(() => {
+ const id = reviewingStillId.value
+ if (!id) return 0
+ const index = outputFamilyStills.value.findIndex(still => still.id === id)
+ return index < 0 ? 0 : index
+})
type LibraryEntry = (typeof libraryEntries.value)[number]
function isLibraryEntryFocused(entry: LibraryEntry) {
if (entry.kind === 'clip' && reviewingClipId.value) {
@@ -3468,13 +3517,14 @@ async function pollLoras() {
const listed = await $fetch<{ image?: string[]; video?: string[] }>('/api/loras').catch(() => ({ image: [], video: [] }))
const nextVideo = listed.video || []
const nextImage = listed.image || []
- if (nextVideo.length) {
+ const sameNames = (a: string[], b: string[]) => a.length === b.length && a.every((name, i) => name === b[i])
+ if (nextVideo.length && !sameNames(videoLoras.value, nextVideo)) {
videoLoras.value = nextVideo
rememberLoraNames('aigen-lora-names-video', nextVideo)
} else if (!videoLoras.value.length) {
videoLoras.value = readStoredLoraNames('aigen-lora-names-video')
}
- if (nextImage.length) {
+ if (nextImage.length && !sameNames(imageLoras.value, nextImage)) {
imageLoras.value = nextImage
rememberLoraNames('aigen-lora-names-image', nextImage)
} else if (!imageLoras.value.length) {
@@ -3487,6 +3537,37 @@ async function pollComfyHealth() {
applyComfyHealth(health)
}
+async function pollStudioBackground() {
+ await pollComfyHealth()
+ if (Date.now() - loraPollAt > 30_000) {
+ loraPollAt = Date.now()
+ void pollLoras()
+ }
+ void refreshStudioQueue()
+ if (activeQueueId.value) void refreshLiveQueue()
+}
+
+function studioBackgroundBusy() {
+ return videoBusy.value
+ || editBusy.value
+ || comfyBusy.value
+ || imageComfyBusy.value
+ || studioJobs.value.some(job => job.status === 'running' || job.status === 'waiting' || job.status === 'held')
+}
+
+function scheduleStudioBackground() {
+ if (!studioPollAlive) return
+ const ms = studioBackgroundBusy() ? 8000 : 20000
+ healthTimer = setTimeout(() => {
+ if (!studioPollAlive) return
+ const run = typeof document === 'undefined' || !document.hidden
+ const done = run ? pollStudioBackground() : Promise.resolve()
+ void done.finally(() => {
+ if (studioPollAlive) scheduleStudioBackground()
+ })
+ }, ms)
+}
+
async function pokeGpu() {
if (gpuWaking.value) return
gpuWaking.value = true
@@ -3550,7 +3631,7 @@ onMounted(async () => {
if (savedPreset) recommendPresetId.value = savedPreset
} catch { /* ignore */ }
applyInstancePrivacy()
- window.addEventListener('click', onSitePointerDown, true)
+ bindConcealListeners()
const [health, me] = await Promise.all([
$fetch<{ comfy?: { ok?: boolean; busy?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } })),
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })),
@@ -3558,6 +3639,7 @@ onMounted(async () => {
])
applyComfyHealth(health)
void pollLoras()
+ loraPollAt = Date.now()
void loadGenerationPresets()
await loadDirectorScripts().catch(() => null)
userName.value = me.user?.name || me.user?.email || ''
@@ -3565,25 +3647,22 @@ onMounted(async () => {
instanceName.value = me.instanceName || instanceName.value || 'AIGen'
applyInstancePrivacy()
setTimeout(() => { void loadLibrary(true).catch(() => null) }, 6000)
- healthTimer = setInterval(() => {
- if (typeof document !== 'undefined' && document.hidden) return
- void pollComfyHealth()
- void pollLoras()
- void refreshStudioQueue()
- if (activeQueueId.value) void refreshLiveQueue()
- }, 5000)
+ studioPollAlive = true
+ scheduleStudioBackground()
await resumeActiveJob()
await refreshStudioQueue()
await refreshLiveQueue()
await loadPromptPresets()
if (route.query.clip || route.query.still) applyIncomingLibraryItem()
else applyIncomingDirectorScript()
+ window.addEventListener('keydown', onStudioKey)
})
onBeforeUnmount(() => {
- window.removeEventListener('click', onSitePointerDown, true)
- window.removeEventListener('keydown', onEditLightboxKey)
- if (healthTimer) clearInterval(healthTimer)
+ unbindConcealListeners()
+ window.removeEventListener('keydown', onStudioKey)
+ studioPollAlive = false
+ if (healthTimer) clearTimeout(healthTimer)
healthTimer = null
if (libraryRefreshTimer) clearTimeout(libraryRefreshTimer)
libraryRefreshTimer = null
@@ -3623,11 +3702,6 @@ watch(videoBusy, (busy) => {
if (busy) outputFocus.value = 'video'
})
-watch(editLightboxUrl, (value) => {
- if (value) window.addEventListener('keydown', onEditLightboxKey)
- else window.removeEventListener('keydown', onEditLightboxKey)
-})
-
watch([editResultUrl, editAwaitingReveal, editLockedSave], () => {
if (!editResultUrl.value || editAwaitingReveal.value || editLockedSave.value) closeEditLightbox()
})
@@ -4593,7 +4667,7 @@ function startTimer(kind: 'video' | 'edit', offset = 0) {
const started = Date.now() - offset
const handle = setInterval(() => {
elapsed.value = Date.now() - started
- }, 250)
+ }, 1000)
if (kind === 'edit') editTimer = handle
else videoTimer = handle
}
@@ -4938,11 +5012,124 @@ function closeEditLightbox() {
editLightboxUrl.value = ''
}
-function onEditLightboxKey(event: KeyboardEvent) {
- if (event.key === 'Escape') closeEditLightbox()
+function typingInField(event: KeyboardEvent) {
+ const target = event.target as HTMLElement | null
+ if (!target) return false
+ const tag = target.tagName
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
}
-function onSitePointerDown(event: Event) {
+function onStudioKey(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ if (editLightboxUrl.value) closeEditLightbox()
+ return
+ }
+ if (typingInField(event)) return
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
+ if (outputFamilyStills.value.length < 2) return
+ event.preventDefault()
+ stepOutputFamily(event.key === 'ArrowLeft' ? -1 : 1)
+}
+
+function showOutputStill(still: LibraryStill) {
+ editResultUrl.value = `/api/library/stills/${still.id}`
+ lastV2StillId.value = still.id
+ editDownloadName.value = clipFileName(stillTitle(still))
+ if (editLightboxUrl.value) editLightboxUrl.value = `/api/library/stills/${still.id}`
+}
+
+function stepOutputFamily(delta: number) {
+ const family = outputFamilyStills.value
+ if (family.length < 2) return
+ const current = outputFamilyIndex.value
+ const next = family[(current + delta + family.length) % family.length]
+ if (next) showOutputStill(next)
+}
+
+function markConcealGesture() {
+ concealGestureAt = Date.now()
+}
+
+function bindConcealListeners() {
+ window.addEventListener('pointerdown', onConcealPointerDown, true)
+ window.addEventListener('pointermove', onConcealPointerMove, true)
+ window.addEventListener('pointerup', onConcealPointerUp, true)
+ window.addEventListener('pointercancel', onConcealPointerCancel, true)
+ window.addEventListener('touchstart', onConcealTouchStart, { capture: true, passive: true })
+ window.addEventListener('scroll', markConcealGesture, true)
+ window.addEventListener('gesturestart', markConcealGesture, true)
+ const viewport = window.visualViewport
+ if (viewport) {
+ viewport.addEventListener('resize', markConcealGesture)
+ viewport.addEventListener('scroll', markConcealGesture)
+ }
+}
+
+function unbindConcealListeners() {
+ window.removeEventListener('pointerdown', onConcealPointerDown, true)
+ window.removeEventListener('pointermove', onConcealPointerMove, true)
+ window.removeEventListener('pointerup', onConcealPointerUp, true)
+ window.removeEventListener('pointercancel', onConcealPointerCancel, true)
+ window.removeEventListener('touchstart', onConcealTouchStart, true)
+ window.removeEventListener('scroll', markConcealGesture, true)
+ window.removeEventListener('gesturestart', markConcealGesture, true)
+ const viewport = window.visualViewport
+ if (viewport) {
+ viewport.removeEventListener('resize', markConcealGesture)
+ viewport.removeEventListener('scroll', markConcealGesture)
+ }
+}
+
+function onConcealPointerDown(event: PointerEvent) {
+ concealPointers += 1
+ if (concealPointers > 1) {
+ concealPinch = true
+ markConcealGesture()
+ }
+ if (concealPointers === 1) {
+ concealStart = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, moved: false }
+ }
+}
+
+function onConcealPointerMove(event: PointerEvent) {
+ const start = concealStart
+ if (!start || event.pointerId !== start.pointerId) return
+ const dx = event.clientX - start.x
+ const dy = event.clientY - start.y
+ if ((dx * dx) + (dy * dy) > CONCEAL_MOVE_PX * CONCEAL_MOVE_PX) start.moved = true
+}
+
+function onConcealPointerUp(event: PointerEvent) {
+ concealPointers = Math.max(0, concealPointers - 1)
+ const start = concealStart
+ const pinched = concealPinch
+ if (event.pointerId === start?.pointerId) concealStart = null
+ if (concealPointers === 0) concealPinch = false
+ if (!start || event.pointerId !== start.pointerId) return
+ if (start.moved || pinched) {
+ markConcealGesture()
+ return
+ }
+ if (Date.now() - concealGestureAt < CONCEAL_GESTURE_MS) return
+ if (event.pointerType === 'mouse' && event.button !== 0) return
+ concealFromOutsideTap(event)
+}
+
+function onConcealPointerCancel() {
+ concealPointers = Math.max(0, concealPointers - 1)
+ concealStart = null
+ if (concealPointers === 0) concealPinch = false
+ markConcealGesture()
+}
+
+function onConcealTouchStart(event: TouchEvent) {
+ if (event.touches.length > 1) {
+ concealPinch = true
+ markConcealGesture()
+ }
+}
+
+function concealFromOutsideTap(event: Event) {
const target = event.target as Node | null
if (editLightbox.value && target && editLightbox.value.contains(target)) return
if (editLightboxUrl.value) closeEditLightbox()
@@ -5641,7 +5828,7 @@ function clearSelection() {
}
function selectAllLibrary() {
- selectedKeys.value = libraryEntries.value.map(entry => entry.key)
+ selectedKeys.value = visibleLibraryEntries.value.map(entry => entry.key)
lastSelectedKey.value = selectedKeys.value.at(-1) || ''
}
diff --git a/pages/library.vue b/pages/library.vue
index 1021ec4..ad7c3db 100644
--- a/pages/library.vue
+++ b/pages/library.vue
@@ -206,10 +206,17 @@
alt=""
class="aspect-video w-full object-cover"
>
+