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 +
+ 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" > +