Files
aigen/components/ImagePickerModal.vue
T
TowstyandCursor dbe45fd2ff Add generation quality controls and bind them to the MiniMax workflow.
Expose aspect, CFG, FPS, sampler, and scheduler in Generation settings, persist them on clips and held jobs, and inject those values into the Comfy graph. Keep the image picker stills-only and allow renaming library clips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 22:55:06 -05:00

270 lines
11 KiB
Vue

<template>
<Teleport to="body">
<div class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/70 p-4 sm:p-8" @click.self="emit('close')">
<div class="glass w-full max-w-3xl rounded-3xl p-5 sm:p-6">
<div class="flex items-start justify-between gap-3">
<div>
<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>
<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">
<button
v-for="folder in folders"
:key="folder.id"
type="button"
class="rounded-full border px-3 py-1 text-xs"
:class="activeFolderId === folder.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="activeFolderId = folder.id"
>
{{ folder.name }}
<span v-if="folder.protected" class="ml-1">{{ folder.unlocked ? '· unlocked' : '· locked' }}</span>
</button>
</div>
<form v-if="current && current.protected && !current.unlocked" class="mt-6 space-y-3" @submit.prevent="unlock">
<p class="text-sm text-zinc-400">This folder is locked. You can still upload a new still to generate into it; unlock to browse saved images.</p>
<input v-model="password" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Unlock folder</button>
</form>
<div
class="mt-5 cursor-pointer rounded-2xl border border-dashed border-white/15 bg-zinc-950/40 p-6 text-center transition hover:border-amber-300/50"
:class="{ 'border-amber-300/70 bg-amber-400/5': dragging }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
@click="fileInput?.click()"
>
<p class="font-medium">Upload a new still</p>
<p class="mt-1 text-sm text-zinc-500">PNG, JPG, or WEBP · saved into {{ current?.name || 'this folder' }}</p>
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFile">
</div>
<template v-if="current && (!current.protected || current.unlocked)">
<p v-if="!visibleStills.length" class="mt-6 text-sm text-zinc-500">No uploaded stills in this folder yet.</p>
<div v-if="visibleStills.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<article
v-for="still in visibleStills"
:key="still.id"
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950"
>
<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>
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click="removeStill(still)">Delete</button>
</div>
</article>
</div>
</template>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
export interface PickerFolder {
id: string
name: string
protected: boolean
unlocked: boolean
thumbnailDefault: boolean
}
export interface PickerStill {
id: string
folderId: string
filename: string
width: number
height: number
createdAt: number
hideInput?: boolean
}
const props = defineProps<{
folders: PickerFolder[]
stills: PickerStill[]
folderId: string
hideInputPreview?: boolean
}>()
const emit = defineEmits<{
close: []
updated: [payload: unknown]
error: [message: string]
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')
}
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
watch(() => props.folderId, (id) => {
if (id && !props.folders.some(folder => folder.id === activeFolderId.value)) {
activeFolderId.value = 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 hasHiddenItems = computed(() => visibleStills.value.some(still => stillHidden(still)))
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)
}
function toggleRevealAll() {
revealAll.value = !revealAll.value
revealedIds.value = []
}
async function unlock() {
try {
emit('updated', await $fetch('/api/library/unlock', {
method: 'POST',
body: { password: password.value, folderId: current.value?.id }
}))
password.value = ''
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Unlock failed')
}
}
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, props.hideInputPreview === true)
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not save image')
}
}
function onFile(event: Event) {
const input = event.target as HTMLInputElement
const next = input.files?.[0]
if (next) void ingest(next)
input.value = ''
}
function onDrop(event: DragEvent) {
dragging.value = false
const next = event.dataTransfer?.files?.[0]
if (next) void ingest(next)
}
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, still.hideInput === true)
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not load that still')
}
}
async function removeStill(still: PickerStill) {
if (!confirm(`Remove “${still.filename}” from this folder?`)) return
try {
emit('updated', await $fetch(`/api/library/stills/${still.id}`, { method: 'DELETE' }))
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not delete image')
}
}
</script>