Add batch still arrows, hide output only on tap, and cut studio lag.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+46
-19
@@ -47,31 +47,22 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glass {
|
.glass {
|
||||||
background: rgba(24, 24, 27, 0.72);
|
background: rgba(24, 24, 27, 0.88);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
|
||||||
backdrop-filter: blur(18px);
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (min-width: 768px) {
|
||||||
|
.glass {
|
||||||
|
background: rgba(24, 24, 27, 0.72);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-glow {
|
.progress-glow {
|
||||||
box-shadow: 0 0 24px rgba(251, 191, 36, 0.35);
|
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 {
|
.aigen-slider {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -109,7 +100,6 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sensitive-glow {
|
.sensitive-glow {
|
||||||
animation: sensitive-pulse 2.2s ease-in-out infinite;
|
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 50% 45%, rgba(251, 191, 36, 0.22), transparent 42%),
|
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%),
|
radial-gradient(circle at 50% 55%, rgba(244, 114, 182, 0.16), transparent 55%),
|
||||||
@@ -120,3 +110,40 @@ textarea {
|
|||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: auto 15rem;
|
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%);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="sticky top-0 z-20 border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
|
<header class="sticky top-0 z-20 border-b border-white/5 bg-zinc-950/90 sm:bg-zinc-950/80 sm:backdrop-blur-md">
|
||||||
<div
|
<div
|
||||||
class="mx-auto flex items-center justify-between px-4 py-4 sm:px-6"
|
class="mx-auto flex items-center justify-between px-4 py-4 sm:px-6"
|
||||||
:class="wide ? 'max-w-[90rem]' : 'max-w-7xl'"
|
:class="wide ? 'max-w-[90rem]' : 'max-w-7xl'"
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="menuOpen"
|
v-if="menuOpen"
|
||||||
class="absolute left-0 top-full z-30 mt-2 w-52 rounded-2xl border border-white/10 bg-zinc-950/95 p-1 shadow-xl shadow-black/40 backdrop-blur-xl"
|
class="absolute left-0 top-full z-30 mt-2 w-52 rounded-2xl border border-white/10 bg-zinc-950/95 p-1 shadow-xl shadow-black/40"
|
||||||
role="menu"
|
role="menu"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
@@ -285,7 +285,7 @@ onMounted(async () => {
|
|||||||
healthTimer = setInterval(() => {
|
healthTimer = setInterval(() => {
|
||||||
if (typeof document !== 'undefined' && document.hidden) return
|
if (typeof document !== 'undefined' && document.hidden) return
|
||||||
void pollHealth()
|
void pollHealth()
|
||||||
}, 5000)
|
}, 15000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="count > 1" class="pointer-events-none absolute inset-0 z-20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="family-nav family-nav-prev pointer-events-auto"
|
||||||
|
:aria-label="prevLabel"
|
||||||
|
@click.stop="$emit('step', -1)"
|
||||||
|
@pointerdown.stop
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M15 5 8 12l7 7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="family-nav family-nav-next pointer-events-auto"
|
||||||
|
:aria-label="nextLabel"
|
||||||
|
@click.stop="$emit('step', 1)"
|
||||||
|
@pointerdown.stop
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="pointer-events-none absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-zinc-950/75 px-2 py-0.5 text-[11px] tabular-nums text-amber-100">
|
||||||
|
{{ index + 1 }} / {{ count }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
count: number
|
||||||
|
index: number
|
||||||
|
prevLabel?: string
|
||||||
|
nextLabel?: string
|
||||||
|
}>(), {
|
||||||
|
prevLabel: 'Previous image',
|
||||||
|
nextLabel: 'Next image'
|
||||||
|
})
|
||||||
|
defineEmits<{ step: [delta: number] }>()
|
||||||
|
</script>
|
||||||
+223
-36
@@ -1361,6 +1361,7 @@
|
|||||||
:src="editResultUrl"
|
:src="editResultUrl"
|
||||||
alt="Edited still"
|
alt="Edited still"
|
||||||
class="mx-auto max-h-[min(70dvh,24rem)] w-full object-contain"
|
class="mx-auto max-h-[min(70dvh,24rem)] w-full object-contain"
|
||||||
|
decoding="async"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="!editAwaitingReveal"
|
v-if="!editAwaitingReveal"
|
||||||
@@ -1394,6 +1395,11 @@
|
|||||||
>
|
>
|
||||||
Edited still appears here
|
Edited still appears here
|
||||||
</div>
|
</div>
|
||||||
|
<FamilyNav
|
||||||
|
:count="outputFamilyStills.length"
|
||||||
|
:index="outputFamilyIndex"
|
||||||
|
@step="stepOutputFamily"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="editResultUrl && !editLockedSave && !editAwaitingReveal" class="mt-4 flex flex-wrap gap-3">
|
<div v-if="editResultUrl && !editLockedSave && !editAwaitingReveal" class="mt-4 flex flex-wrap gap-3">
|
||||||
<a
|
<a
|
||||||
@@ -1434,12 +1440,12 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<NuxtLink to="/library" class="text-xs text-amber-300 hover:text-amber-100">Open full library</NuxtLink>
|
<NuxtLink to="/library" class="text-xs text-amber-300 hover:text-amber-100">Open full library</NuxtLink>
|
||||||
<button
|
<button
|
||||||
v-if="libraryEntries.length"
|
v-if="visibleLibraryEntries.length"
|
||||||
type="button"
|
type="button"
|
||||||
class="text-xs text-zinc-400 hover:text-white"
|
class="text-xs text-zinc-400 hover:text-white"
|
||||||
@click="selectedKeys.length === libraryEntries.length ? clearSelection() : selectAllLibrary()"
|
@click="selectedKeys.length === visibleLibraryEntries.length ? clearSelection() : selectAllLibrary()"
|
||||||
>
|
>
|
||||||
{{ selectedKeys.length && selectedKeys.length === libraryEntries.length ? 'Clear selection' : 'Select all' }}
|
{{ selectedKeys.length && selectedKeys.length === visibleLibraryEntries.length ? 'Clear selection' : 'Select all' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="browseFolder?.protected && browseFolder.unlocked"
|
v-if="browseFolder?.protected && browseFolder.unlocked"
|
||||||
@@ -1512,11 +1518,11 @@
|
|||||||
<button type="button" class="text-red-300 hover:text-red-200" @click="askRemoveSelected">Delete</button>
|
<button type="button" class="text-red-300 hover:text-red-200" @click="askRemoveSelected">Delete</button>
|
||||||
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
|
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="mt-4 space-y-3">
|
<ul class="mt-4 max-h-[min(28rem,50vh)] space-y-3 overflow-y-auto">
|
||||||
<li
|
<li
|
||||||
v-for="entry in libraryEntries"
|
v-for="entry in visibleLibraryEntries"
|
||||||
:key="entry.key"
|
:key="entry.key"
|
||||||
class="cursor-pointer rounded-2xl border p-3"
|
class="studio-library-card cursor-pointer rounded-2xl border p-3"
|
||||||
:class="libraryEntryClass(entry)"
|
:class="libraryEntryClass(entry)"
|
||||||
:aria-current="isLibraryEntryFocused(entry) ? 'true' : undefined"
|
:aria-current="isLibraryEntryFocused(entry) ? 'true' : undefined"
|
||||||
@click="onLibraryCardClick(entry, $event)"
|
@click="onLibraryCardClick(entry, $event)"
|
||||||
@@ -1871,6 +1877,10 @@
|
|||||||
</template>
|
</template>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p v-if="libraryCapped" class="mt-3 text-xs text-zinc-500">
|
||||||
|
Showing {{ visibleLibraryEntries.length }} of {{ libraryEntries.length }}.
|
||||||
|
<NuxtLink to="/library" class="text-amber-300 hover:text-amber-100">Open full library</NuxtLink>
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1924,12 +1934,19 @@
|
|||||||
aria-label="Edited still fullscreen"
|
aria-label="Edited still fullscreen"
|
||||||
@click.self="closeEditLightbox"
|
@click.self="closeEditLightbox"
|
||||||
>
|
>
|
||||||
<img
|
<div class="relative max-h-[90vh] max-w-[min(96vw,80rem)]" @click.stop>
|
||||||
:src="editLightboxUrl"
|
<img
|
||||||
alt="Edited still"
|
:src="editLightboxUrl"
|
||||||
class="max-h-[90vh] max-w-[min(96vw,80rem)] object-contain"
|
alt="Edited still"
|
||||||
@click.stop
|
class="max-h-[90vh] max-w-[min(96vw,80rem)] object-contain"
|
||||||
>
|
decoding="async"
|
||||||
|
>
|
||||||
|
<FamilyNav
|
||||||
|
:count="outputFamilyStills.length"
|
||||||
|
:index="outputFamilyIndex"
|
||||||
|
@step="stepOutputFamily"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
@@ -2613,7 +2630,15 @@ let videoSource: EventSource | null = null
|
|||||||
let editSource: EventSource | null = null
|
let editSource: EventSource | null = null
|
||||||
let videoPollTimer: ReturnType<typeof setInterval> | null = null
|
let videoPollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let editPollTimer: ReturnType<typeof setInterval> | null = null
|
let editPollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let healthTimer: ReturnType<typeof setInterval> | null = null
|
let healthTimer: ReturnType<typeof setTimeout> | 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 videoSettledUi = false
|
||||||
let editSettledUi = false
|
let editSettledUi = false
|
||||||
let videoListenGen = 0
|
let videoListenGen = 0
|
||||||
@@ -3077,6 +3102,30 @@ const reviewingStillId = computed(() => {
|
|||||||
if (outputPreviewKind.value !== 'edit' || editLockedSave.value) return ''
|
if (outputPreviewKind.value !== 'edit' || editLockedSave.value) return ''
|
||||||
return stillIdFromLibraryUrl(editResultUrl.value)
|
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]
|
type LibraryEntry = (typeof libraryEntries.value)[number]
|
||||||
function isLibraryEntryFocused(entry: LibraryEntry) {
|
function isLibraryEntryFocused(entry: LibraryEntry) {
|
||||||
if (entry.kind === 'clip' && reviewingClipId.value) {
|
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 listed = await $fetch<{ image?: string[]; video?: string[] }>('/api/loras').catch(() => ({ image: [], video: [] }))
|
||||||
const nextVideo = listed.video || []
|
const nextVideo = listed.video || []
|
||||||
const nextImage = listed.image || []
|
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
|
videoLoras.value = nextVideo
|
||||||
rememberLoraNames('aigen-lora-names-video', nextVideo)
|
rememberLoraNames('aigen-lora-names-video', nextVideo)
|
||||||
} else if (!videoLoras.value.length) {
|
} else if (!videoLoras.value.length) {
|
||||||
videoLoras.value = readStoredLoraNames('aigen-lora-names-video')
|
videoLoras.value = readStoredLoraNames('aigen-lora-names-video')
|
||||||
}
|
}
|
||||||
if (nextImage.length) {
|
if (nextImage.length && !sameNames(imageLoras.value, nextImage)) {
|
||||||
imageLoras.value = nextImage
|
imageLoras.value = nextImage
|
||||||
rememberLoraNames('aigen-lora-names-image', nextImage)
|
rememberLoraNames('aigen-lora-names-image', nextImage)
|
||||||
} else if (!imageLoras.value.length) {
|
} else if (!imageLoras.value.length) {
|
||||||
@@ -3487,6 +3537,37 @@ async function pollComfyHealth() {
|
|||||||
applyComfyHealth(health)
|
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() {
|
async function pokeGpu() {
|
||||||
if (gpuWaking.value) return
|
if (gpuWaking.value) return
|
||||||
gpuWaking.value = true
|
gpuWaking.value = true
|
||||||
@@ -3550,7 +3631,7 @@ onMounted(async () => {
|
|||||||
if (savedPreset) recommendPresetId.value = savedPreset
|
if (savedPreset) recommendPresetId.value = savedPreset
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
applyInstancePrivacy()
|
applyInstancePrivacy()
|
||||||
window.addEventListener('click', onSitePointerDown, true)
|
bindConcealListeners()
|
||||||
const [health, me] = await Promise.all([
|
const [health, me] = await Promise.all([
|
||||||
$fetch<{ comfy?: { ok?: boolean; busy?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } })),
|
$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 })),
|
$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)
|
applyComfyHealth(health)
|
||||||
void pollLoras()
|
void pollLoras()
|
||||||
|
loraPollAt = Date.now()
|
||||||
void loadGenerationPresets()
|
void loadGenerationPresets()
|
||||||
await loadDirectorScripts().catch(() => null)
|
await loadDirectorScripts().catch(() => null)
|
||||||
userName.value = me.user?.name || me.user?.email || ''
|
userName.value = me.user?.name || me.user?.email || ''
|
||||||
@@ -3565,25 +3647,22 @@ onMounted(async () => {
|
|||||||
instanceName.value = me.instanceName || instanceName.value || 'AIGen'
|
instanceName.value = me.instanceName || instanceName.value || 'AIGen'
|
||||||
applyInstancePrivacy()
|
applyInstancePrivacy()
|
||||||
setTimeout(() => { void loadLibrary(true).catch(() => null) }, 6000)
|
setTimeout(() => { void loadLibrary(true).catch(() => null) }, 6000)
|
||||||
healthTimer = setInterval(() => {
|
studioPollAlive = true
|
||||||
if (typeof document !== 'undefined' && document.hidden) return
|
scheduleStudioBackground()
|
||||||
void pollComfyHealth()
|
|
||||||
void pollLoras()
|
|
||||||
void refreshStudioQueue()
|
|
||||||
if (activeQueueId.value) void refreshLiveQueue()
|
|
||||||
}, 5000)
|
|
||||||
await resumeActiveJob()
|
await resumeActiveJob()
|
||||||
await refreshStudioQueue()
|
await refreshStudioQueue()
|
||||||
await refreshLiveQueue()
|
await refreshLiveQueue()
|
||||||
await loadPromptPresets()
|
await loadPromptPresets()
|
||||||
if (route.query.clip || route.query.still) applyIncomingLibraryItem()
|
if (route.query.clip || route.query.still) applyIncomingLibraryItem()
|
||||||
else applyIncomingDirectorScript()
|
else applyIncomingDirectorScript()
|
||||||
|
window.addEventListener('keydown', onStudioKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('click', onSitePointerDown, true)
|
unbindConcealListeners()
|
||||||
window.removeEventListener('keydown', onEditLightboxKey)
|
window.removeEventListener('keydown', onStudioKey)
|
||||||
if (healthTimer) clearInterval(healthTimer)
|
studioPollAlive = false
|
||||||
|
if (healthTimer) clearTimeout(healthTimer)
|
||||||
healthTimer = null
|
healthTimer = null
|
||||||
if (libraryRefreshTimer) clearTimeout(libraryRefreshTimer)
|
if (libraryRefreshTimer) clearTimeout(libraryRefreshTimer)
|
||||||
libraryRefreshTimer = null
|
libraryRefreshTimer = null
|
||||||
@@ -3623,11 +3702,6 @@ watch(videoBusy, (busy) => {
|
|||||||
if (busy) outputFocus.value = 'video'
|
if (busy) outputFocus.value = 'video'
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(editLightboxUrl, (value) => {
|
|
||||||
if (value) window.addEventListener('keydown', onEditLightboxKey)
|
|
||||||
else window.removeEventListener('keydown', onEditLightboxKey)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch([editResultUrl, editAwaitingReveal, editLockedSave], () => {
|
watch([editResultUrl, editAwaitingReveal, editLockedSave], () => {
|
||||||
if (!editResultUrl.value || editAwaitingReveal.value || editLockedSave.value) closeEditLightbox()
|
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 started = Date.now() - offset
|
||||||
const handle = setInterval(() => {
|
const handle = setInterval(() => {
|
||||||
elapsed.value = Date.now() - started
|
elapsed.value = Date.now() - started
|
||||||
}, 250)
|
}, 1000)
|
||||||
if (kind === 'edit') editTimer = handle
|
if (kind === 'edit') editTimer = handle
|
||||||
else videoTimer = handle
|
else videoTimer = handle
|
||||||
}
|
}
|
||||||
@@ -4938,11 +5012,124 @@ function closeEditLightbox() {
|
|||||||
editLightboxUrl.value = ''
|
editLightboxUrl.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEditLightboxKey(event: KeyboardEvent) {
|
function typingInField(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') closeEditLightbox()
|
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
|
const target = event.target as Node | null
|
||||||
if (editLightbox.value && target && editLightbox.value.contains(target)) return
|
if (editLightbox.value && target && editLightbox.value.contains(target)) return
|
||||||
if (editLightboxUrl.value) closeEditLightbox()
|
if (editLightboxUrl.value) closeEditLightbox()
|
||||||
@@ -5641,7 +5828,7 @@ function clearSelection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectAllLibrary() {
|
function selectAllLibrary() {
|
||||||
selectedKeys.value = libraryEntries.value.map(entry => entry.key)
|
selectedKeys.value = visibleLibraryEntries.value.map(entry => entry.key)
|
||||||
lastSelectedKey.value = selectedKeys.value.at(-1) || ''
|
lastSelectedKey.value = selectedKeys.value.at(-1) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-1
@@ -206,10 +206,17 @@
|
|||||||
alt=""
|
alt=""
|
||||||
class="aspect-video w-full object-cover"
|
class="aspect-video w-full object-cover"
|
||||||
>
|
>
|
||||||
|
<FamilyNav
|
||||||
|
:count="focusedNavParts.length"
|
||||||
|
:index="focusedNavIndex"
|
||||||
|
:prev-label="focused?.kind === 'clip' ? 'Previous clip' : 'Previous image'"
|
||||||
|
:next-label="focused?.kind === 'clip' ? 'Next clip' : 'Next image'"
|
||||||
|
@step="stepFocusedFamily"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="showPrivacyToggles"
|
v-if="showPrivacyToggles"
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute right-2 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full border border-white/20 bg-black/60 text-zinc-100 hover:border-amber-300/50 hover:text-amber-100"
|
class="absolute right-2 top-2 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-white/20 bg-black/60 text-zinc-100 hover:border-amber-300/50 hover:text-amber-100"
|
||||||
:title="detailsHidden ? 'Show preview' : 'Hide preview'"
|
:title="detailsHidden ? 'Show preview' : 'Hide preview'"
|
||||||
:aria-label="detailsHidden ? 'Show preview' : 'Hide preview'"
|
:aria-label="detailsHidden ? 'Show preview' : 'Hide preview'"
|
||||||
@click.stop="detailsHidden = !detailsHidden"
|
@click.stop="detailsHidden = !detailsHidden"
|
||||||
@@ -329,6 +336,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
buildLibraryEntries,
|
buildLibraryEntries,
|
||||||
|
chronologicalParts,
|
||||||
|
chronologicalStills,
|
||||||
formatClipDuration,
|
formatClipDuration,
|
||||||
formatWhen,
|
formatWhen,
|
||||||
type LibraryClip,
|
type LibraryClip,
|
||||||
@@ -458,6 +467,17 @@ const focusedParts = computed(() => {
|
|||||||
if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
|
if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
|
||||||
return focused.value?.stillGroup?.parts || []
|
return focused.value?.stillGroup?.parts || []
|
||||||
})
|
})
|
||||||
|
const focusedNavParts = computed(() => {
|
||||||
|
const parts = focusedParts.value
|
||||||
|
if (parts.length < 2) return []
|
||||||
|
if (focused.value?.kind === 'still') return chronologicalStills(parts as LibraryStill[])
|
||||||
|
return chronologicalParts(parts as LibraryClip[])
|
||||||
|
})
|
||||||
|
const focusedNavIndex = computed(() => {
|
||||||
|
const id = focusedPartId.value
|
||||||
|
const index = focusedNavParts.value.findIndex(part => part.id === id)
|
||||||
|
return index < 0 ? 0 : index
|
||||||
|
})
|
||||||
const detailPrompt = computed(() => {
|
const detailPrompt = computed(() => {
|
||||||
if (focused.value?.kind === 'clip') return focused.value.group?.head.prompt || ''
|
if (focused.value?.kind === 'clip') return focused.value.group?.head.prompt || ''
|
||||||
return focused.value?.stillGroup?.head.prompt || ''
|
return focused.value?.stillGroup?.head.prompt || ''
|
||||||
@@ -559,6 +579,13 @@ function focusPart(id: string) {
|
|||||||
focusedPartId.value = id
|
focusedPartId.value = id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stepFocusedFamily(delta: number) {
|
||||||
|
const parts = focusedNavParts.value
|
||||||
|
if (parts.length < 2) return
|
||||||
|
const next = parts[(focusedNavIndex.value + delta + parts.length) % parts.length]
|
||||||
|
if (next) focusPart(next.id)
|
||||||
|
}
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
if (scrollRaf) return
|
if (scrollRaf) return
|
||||||
scrollRaf = requestAnimationFrame(() => {
|
scrollRaf = requestAnimationFrame(() => {
|
||||||
@@ -806,6 +833,8 @@ async function lockFolder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKey(event: KeyboardEvent) {
|
function onKey(event: KeyboardEvent) {
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
const typing = Boolean(target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable))
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
if (pendingDelete.value) pendingDelete.value = null
|
if (pendingDelete.value) pendingDelete.value = null
|
||||||
else if (renaming.value) cancelRename()
|
else if (renaming.value) cancelRename()
|
||||||
@@ -816,6 +845,10 @@ function onKey(event: KeyboardEvent) {
|
|||||||
selectAll()
|
selectAll()
|
||||||
}
|
}
|
||||||
if (event.key === 'Delete' && selectedKeys.value.length && !renaming.value) askRemoveSelected()
|
if (event.key === 'Delete' && selectedKeys.value.length && !renaming.value) askRemoveSelected()
|
||||||
|
if (!typing && (event.key === 'ArrowLeft' || event.key === 'ArrowRight') && focusedNavParts.value.length > 1) {
|
||||||
|
event.preventDefault()
|
||||||
|
stepFocusedFamily(event.key === 'ArrowLeft' ? -1 : 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(searchDraft, (value) => {
|
watch(searchDraft, (value) => {
|
||||||
|
|||||||
+1
-1
@@ -1023,7 +1023,7 @@ onMounted(async () => {
|
|||||||
pollTimer = setInterval(() => {
|
pollTimer = setInterval(() => {
|
||||||
if (typeof document !== 'undefined' && document.hidden) return
|
if (typeof document !== 'undefined' && document.hidden) return
|
||||||
void loadQueues().catch(() => null)
|
void loadQueues().catch(() => null)
|
||||||
}, 4000)
|
}, 8000)
|
||||||
})
|
})
|
||||||
|
|
||||||
function onRemoveKey(event: KeyboardEvent) {
|
function onRemoveKey(event: KeyboardEvent) {
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { comfyConfigured } from '~/server/utils/comfy'
|
|||||||
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost } from '~/server/utils/imageComfy'
|
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost } from '~/server/utils/imageComfy'
|
||||||
import { checkImageComfyHttp, fetchQueueAt } from '~/server/utils/imageComfyLifecycle'
|
import { checkImageComfyHttp, fetchQueueAt } from '~/server/utils/imageComfyLifecycle'
|
||||||
|
|
||||||
|
let healthCache: { at: number; body: Record<string, unknown> } | null = null
|
||||||
|
const HEALTH_CACHE_MS = 2500
|
||||||
|
|
||||||
export default defineEventHandler(async () => {
|
export default defineEventHandler(async () => {
|
||||||
|
if (healthCache && Date.now() - healthCache.at < HEALTH_CACHE_MS) return healthCache.body
|
||||||
const videoConfigured = comfyConfigured()
|
const videoConfigured = comfyConfigured()
|
||||||
const imageConfigured = imageComfyConfigured()
|
const imageConfigured = imageComfyConfigured()
|
||||||
const beastHost = getBeastImageHost()
|
const beastHost = getBeastImageHost()
|
||||||
@@ -35,7 +39,7 @@ export default defineEventHandler(async () => {
|
|||||||
const preferredHost = beastHost || comfy.host || ''
|
const preferredHost = beastHost || comfy.host || ''
|
||||||
const promptQueueRunning = promptIsBeast ? ((beastQueue.running || queue.running) || 0) : (promptQueue.running || 0)
|
const promptQueueRunning = promptIsBeast ? ((beastQueue.running || queue.running) || 0) : (promptQueue.running || 0)
|
||||||
const promptQueuePending = promptIsBeast ? ((beastQueue.pending || queue.pending) || 0) : (promptQueue.pending || 0)
|
const promptQueuePending = promptIsBeast ? ((beastQueue.pending || queue.pending) || 0) : (promptQueue.pending || 0)
|
||||||
return {
|
const body = {
|
||||||
ok: true,
|
ok: true,
|
||||||
service: useRuntimeConfig().public.instanceName || 'aigen',
|
service: useRuntimeConfig().public.instanceName || 'aigen',
|
||||||
comfy: {
|
comfy: {
|
||||||
@@ -75,4 +79,6 @@ export default defineEventHandler(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
healthCache = { at: Date.now(), body }
|
||||||
|
return body
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -93,6 +93,17 @@ export function chronologicalStills(parts: LibraryStill[]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stillsInFamily(stills: LibraryStill[], current?: LibraryStill | null) {
|
||||||
|
if (!current) return [] as LibraryStill[]
|
||||||
|
if (!current.familyId) return [current]
|
||||||
|
return chronologicalStills(stills.filter(item => item.familyId === current.familyId && item.role !== 'input'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrappedIndex(length: number, current: number, delta: number) {
|
||||||
|
if (length <= 0) return 0
|
||||||
|
return ((current + delta) % length + length) % length
|
||||||
|
}
|
||||||
|
|
||||||
export function sortClipParts(a: LibraryClip, b: LibraryClip) {
|
export function sortClipParts(a: LibraryClip, b: LibraryClip) {
|
||||||
const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0)
|
const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0)
|
||||||
if (durationDelta) return durationDelta
|
if (durationDelta) return durationDelta
|
||||||
|
|||||||
Reference in New Issue
Block a user