Let clips move between folders, save busy jobs, and fail dropped Comfy runs.
Held jobs restore the form after a 3-minute wait, hidden stills stay blurred in the picker, and reloading Comfy no longer leaves xaigen spinning. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+162
-28
@@ -7,7 +7,28 @@
|
||||
<h2 class="font-display text-2xl font-bold">Choose an image</h2>
|
||||
<p class="mt-1 text-sm text-zinc-400">Pick a still from this folder or upload a new one.</p>
|
||||
</div>
|
||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="emit('close')">Close</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="hasHiddenItems"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-sm"
|
||||
:class="revealAll ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||||
:title="revealAll ? 'Hide flagged thumbnails' : 'Reveal all hidden thumbnails'"
|
||||
@click="toggleRevealAll"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
||||
<path v-if="revealAll" stroke-linecap="round" d="M3 3l18 18M10.6 10.6a2 2 0 102.8 2.8M9.9 5.1A9.8 9.8 0 0121 12a9.8 9.8 0 01-1.7 2.9M6.1 6.1A9.8 9.8 0 003 12a9.8 9.8 0 0012.9 6.1" />
|
||||
<template v-else>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</template>
|
||||
</svg>
|
||||
{{ revealAll ? 'Hide all' : 'Reveal all' }}
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-2">
|
||||
@@ -44,42 +65,114 @@
|
||||
</div>
|
||||
|
||||
<template v-if="current && (!current.protected || current.unlocked)">
|
||||
|
||||
<p v-if="!visibleStills.length && !visibleClipThumbs.length" class="mt-6 text-sm text-zinc-500">No uploaded stills in this folder yet.</p>
|
||||
<div v-if="visibleStills.length || visibleClipThumbs.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
<button
|
||||
<article
|
||||
v-for="still in visibleStills"
|
||||
:key="still.id"
|
||||
type="button"
|
||||
class="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 text-left hover:border-amber-300/50"
|
||||
@click="pickStill(still)"
|
||||
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950"
|
||||
>
|
||||
<img :src="`/api/library/stills/${still.id}`" alt="" class="h-28 w-full object-cover">
|
||||
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
:src="`/api/library/stills/${still.id}`"
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
:class="stillHidden(still) && !isRevealed(still.id) ? 'scale-110 blur-xl' : ''"
|
||||
>
|
||||
<span v-if="stillHidden(still) && !isRevealed(still.id)" class="sensitive-glow pointer-events-none absolute inset-0 opacity-80" />
|
||||
<div class="absolute inset-x-0 top-0 flex items-start justify-between p-2">
|
||||
<button
|
||||
v-if="stillHidden(still)"
|
||||
type="button"
|
||||
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900"
|
||||
:title="isRevealed(still.id) ? 'Hide thumbnail' : 'Reveal thumbnail'"
|
||||
@click="toggleReveal(still.id)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
||||
<template v-if="isRevealed(still.id)">
|
||||
<path stroke-linecap="round" d="M3 3l18 18M10.6 10.6a2 2 0 102.8 2.8M9.9 5.1A9.8 9.8 0 0121 12a9.8 9.8 0 01-1.7 2.9M6.1 6.1A9.8 9.8 0 003 12a9.8 9.8 0 0012.9 6.1" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</template>
|
||||
</svg>
|
||||
</button>
|
||||
<span v-else />
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
|
||||
title="Use as input"
|
||||
@click="pickStill(still)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="h-4 w-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<span class="truncate text-xs text-zinc-400">{{ still.filename }}</span>
|
||||
<span class="text-[10px] text-red-300 opacity-0 group-hover:opacity-100" @click.stop="removeStill(still)">Delete</span>
|
||||
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click="removeStill(still)">Delete</button>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
</article>
|
||||
<article
|
||||
v-for="clip in visibleClipThumbs"
|
||||
:key="'clip-' + clip.id"
|
||||
type="button"
|
||||
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 text-left hover:border-amber-300/50"
|
||||
@click="pickClip(clip)"
|
||||
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950"
|
||||
>
|
||||
<img v-if="clip.hasThumbnail" :src="`/api/library/clips/${clip.id}/thumb`" alt="" class="h-28 w-full object-cover">
|
||||
<video
|
||||
v-else
|
||||
:src="`/api/library/clips/${clip.id}/video#t=0.1`"
|
||||
class="h-28 w-full object-cover"
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
/>
|
||||
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
v-if="clip.hasThumbnail || clip.hideThumbnail"
|
||||
:src="`/api/library/clips/${clip.id}/thumb`"
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
:class="clip.hideThumbnail && !isRevealed('clip-' + clip.id) ? 'scale-110 blur-xl' : ''"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="`/api/library/clips/${clip.id}/video#t=0.1`"
|
||||
class="h-full w-full object-cover"
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
/>
|
||||
<span v-if="clip.hideThumbnail && !isRevealed('clip-' + clip.id)" class="sensitive-glow pointer-events-none absolute inset-0 opacity-80" />
|
||||
<div class="absolute inset-x-0 top-0 flex items-start justify-between p-2">
|
||||
<button
|
||||
v-if="clip.hideThumbnail"
|
||||
type="button"
|
||||
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900"
|
||||
:title="isRevealed('clip-' + clip.id) ? 'Hide thumbnail' : 'Reveal thumbnail'"
|
||||
@click="toggleReveal('clip-' + clip.id)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
||||
<template v-if="isRevealed('clip-' + clip.id)">
|
||||
<path stroke-linecap="round" d="M3 3l18 18M10.6 10.6a2 2 0 102.8 2.8M9.9 5.1A9.8 9.8 0 0121 12a9.8 9.8 0 01-1.7 2.9M6.1 6.1A9.8 9.8 0 003 12a9.8 9.8 0 0012.9 6.1" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</template>
|
||||
</svg>
|
||||
</button>
|
||||
<span v-else />
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
|
||||
title="Use as input"
|
||||
@click="pickClip(clip)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="h-4 w-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 py-2">
|
||||
<span class="truncate text-xs text-zinc-400">{{ clip.name || 'From a clip' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -103,6 +196,7 @@ export interface PickerStill {
|
||||
width: number
|
||||
height: number
|
||||
createdAt: number
|
||||
hideInput?: boolean
|
||||
}
|
||||
|
||||
export interface PickerClip {
|
||||
@@ -119,19 +213,22 @@ const props = defineProps<{
|
||||
stills: PickerStill[]
|
||||
clips: PickerClip[]
|
||||
folderId: string
|
||||
hideInputPreview?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
updated: [payload: unknown]
|
||||
error: [message: string]
|
||||
select: [file: File]
|
||||
select: [file: File, hidden: boolean]
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
const password = ref('')
|
||||
const activeFolderId = ref(props.folderId)
|
||||
const revealAll = ref(false)
|
||||
const revealedIds = ref<string[]>([])
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') emit('close')
|
||||
@@ -148,7 +245,43 @@ watch(() => props.folderId, (id) => {
|
||||
|
||||
const current = computed(() => props.folders.find(folder => folder.id === activeFolderId.value) || props.folders[0])
|
||||
const visibleStills = computed(() => props.stills.filter(still => still.folderId === (current.value?.id || activeFolderId.value)))
|
||||
const visibleClipThumbs = computed(() => props.clips.filter(clip => clip.folderId === (current.value?.id || activeFolderId.value) && !clip.hideThumbnail))
|
||||
const visibleClipThumbs = computed(() => props.clips.filter(clip => clip.folderId === (current.value?.id || activeFolderId.value)))
|
||||
const hasHiddenItems = computed(() => {
|
||||
return visibleStills.value.some(still => stillHidden(still)) || visibleClipThumbs.value.some(clip => clip.hideThumbnail)
|
||||
})
|
||||
|
||||
function stillHidden(still: PickerStill) {
|
||||
return still.hideInput === true || props.hideInputPreview === true
|
||||
}
|
||||
|
||||
function isRevealed(id: string) {
|
||||
return revealAll.value || revealedIds.value.includes(id)
|
||||
}
|
||||
|
||||
function toggleReveal(id: string) {
|
||||
if (revealAll.value) {
|
||||
revealAll.value = false
|
||||
revealedIds.value = visibleHiddenIds().filter(item => item !== id)
|
||||
return
|
||||
}
|
||||
if (revealedIds.value.includes(id)) {
|
||||
revealedIds.value = revealedIds.value.filter(item => item !== id)
|
||||
} else {
|
||||
revealedIds.value = [...revealedIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
function visibleHiddenIds() {
|
||||
return [
|
||||
...visibleStills.value.filter(still => stillHidden(still)).map(still => still.id),
|
||||
...visibleClipThumbs.value.filter(clip => clip.hideThumbnail).map(clip => 'clip-' + clip.id)
|
||||
]
|
||||
}
|
||||
|
||||
function toggleRevealAll() {
|
||||
revealAll.value = !revealAll.value
|
||||
revealedIds.value = []
|
||||
}
|
||||
|
||||
async function unlock() {
|
||||
try {
|
||||
@@ -166,9 +299,10 @@ async function ingest(file: File) {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
body.append('folderId', current.value?.id || activeFolderId.value)
|
||||
body.append('hideInput', String(props.hideInputPreview === true))
|
||||
try {
|
||||
emit('updated', await $fetch('/api/library/stills', { method: 'POST', body }))
|
||||
emit('select', file)
|
||||
emit('select', file, props.hideInputPreview === true)
|
||||
} catch (error: any) {
|
||||
emit('error', error?.data?.statusMessage || 'Could not save image')
|
||||
}
|
||||
@@ -191,7 +325,7 @@ async function pickClip(clip: PickerClip) {
|
||||
try {
|
||||
const blob = await $fetch<Blob>(`/api/library/clips/${clip.id}/thumb`, { responseType: 'blob' })
|
||||
const file = new File([blob], `${clip.id}.png`, { type: blob.type || 'image/png' })
|
||||
emit('select', file)
|
||||
emit('select', file, clip.hideThumbnail === true)
|
||||
} catch (error: any) {
|
||||
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
||||
}
|
||||
@@ -201,7 +335,7 @@ async function pickStill(still: PickerStill) {
|
||||
try {
|
||||
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
||||
const file = new File([blob], still.filename, { type: blob.type || 'image/png' })
|
||||
emit('select', file)
|
||||
emit('select', file, still.hideInput === true)
|
||||
} catch (error: any) {
|
||||
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user