Keep uploaded stills in the input picker and generated work in the library, and run image-edit passes as a chain like video extensions.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-display text-2xl font-bold">{{ heading || '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>
|
||||
<p class="mt-1 text-sm text-zinc-400">{{ tab === 'input' ? 'Uploaded stills only. Generated frames and edits live under Output.' : 'Generated stills from image edits. Video frames stay with their clips.' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@@ -32,6 +32,25 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="tab === 'input' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
|
||||
@click="tab = 'input'"
|
||||
>
|
||||
Input
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="tab === 'output' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
|
||||
@click="tab = 'output'"
|
||||
>
|
||||
Output
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
@@ -52,6 +71,7 @@
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="tab === 'input'"
|
||||
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"
|
||||
@@ -65,7 +85,7 @@
|
||||
</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>
|
||||
<p v-if="!visibleStills.length" class="mt-6 text-sm text-zinc-500">{{ emptyLabel }}</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"
|
||||
@@ -112,7 +132,7 @@
|
||||
</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="truncate text-xs text-zinc-400">{{ still.name || still.filename }}</span>
|
||||
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click="removeStill(still)">Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -140,6 +160,8 @@ export interface PickerStill {
|
||||
height: number
|
||||
createdAt: number
|
||||
hideInput?: boolean
|
||||
role?: 'input' | 'output'
|
||||
name?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -161,6 +183,7 @@ const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
const password = ref('')
|
||||
const activeFolderId = ref(props.folderId)
|
||||
const tab = ref<'input' | 'output'>('input')
|
||||
const revealAll = ref(false)
|
||||
const revealedIds = ref<string[]>([])
|
||||
|
||||
@@ -178,7 +201,14 @@ 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 visibleStills = computed(() => props.stills.filter((still) => {
|
||||
if (still.folderId !== (current.value?.id || activeFolderId.value)) return false
|
||||
const role = still.role === 'output' ? 'output' : 'input'
|
||||
return role === tab.value
|
||||
}))
|
||||
const emptyLabel = computed(() => tab.value === 'input'
|
||||
? 'No uploaded stills in this folder yet.'
|
||||
: 'No generated stills in this folder yet.')
|
||||
const hasHiddenItems = computed(() => visibleStills.value.some(still => stillHidden(still)))
|
||||
|
||||
function stillHidden(still: PickerStill) {
|
||||
|
||||
+548
-37
@@ -161,6 +161,39 @@
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
:placeholder="promptPlaceholder"
|
||||
/>
|
||||
<div v-if="studioMode === 'edit'" class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="(item, index) in editPassQueue"
|
||||
:key="item.id"
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium text-zinc-200">Pass {{ index + 2 }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-red-300 hover:text-red-200"
|
||||
:disabled="editBusy"
|
||||
@click="removeEditPass(item.id)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="item.prompt"
|
||||
rows="3"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Next edit prompt, applied to the previous pass output…"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-2xl border border-dashed border-white/15 px-4 py-2.5 text-sm text-zinc-300 transition hover:border-amber-300/40 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="editBusy"
|
||||
@click="queueEditPass"
|
||||
>
|
||||
+ Pass
|
||||
</button>
|
||||
</div>
|
||||
<details v-if="identityPrompting && prompt.trim()" class="mt-2 rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2">
|
||||
<summary class="cursor-pointer text-xs text-zinc-400">Prompt sent to Reference to Video</summary>
|
||||
<p class="mt-2 text-[10px] uppercase tracking-wide text-zinc-500">Identity prefix</p>
|
||||
@@ -504,7 +537,7 @@
|
||||
:disabled="studioMode === 'edit' ? editBlocked : videoGenerateDisabled"
|
||||
@click="studioMode === 'edit' ? editImage() : generate()"
|
||||
>
|
||||
{{ studioMode === 'edit' ? (editBusy ? 'Editing…' : 'Edit image') : generateLabel }}
|
||||
{{ studioMode === 'edit' ? (editBusy ? 'Editing…' : editLabel) : generateLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="studioMode === 'edit' ? editBusy : videoBusy"
|
||||
@@ -542,6 +575,7 @@
|
||||
<h2 class="font-display text-2xl font-bold">Output</h2>
|
||||
<p v-if="studioMode === 'edit'" class="text-xs text-zinc-500">
|
||||
{{ editElapsedLabel }}
|
||||
<template v-if="editChainTotal > 1"> · chain {{ editChainStep }}/{{ editChainTotal }}</template>
|
||||
· step {{ editStep }}/{{ editMaxStep }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-zinc-500">
|
||||
@@ -683,8 +717,24 @@
|
||||
<span :class="editStatusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ editStatusMessage }}</span>
|
||||
<span class="text-amber-200">{{ editProgress }}%</span>
|
||||
</div>
|
||||
<p v-if="editChainTotal > 1" class="mb-2 text-xs text-amber-200/80">
|
||||
Step {{ editChainStep }}/{{ editChainTotal }}{{ editChainLabel ? `: ${editChainLabel}` : '' }}
|
||||
<template v-if="editOverallProgress"> · {{ editOverallProgress }}% overall</template>
|
||||
</p>
|
||||
<ol v-if="editBusy && editActiveChainPlan.length > 1" class="mb-3 space-y-1">
|
||||
<li
|
||||
v-for="(part, index) in editActiveChainPlan"
|
||||
:key="index"
|
||||
class="flex items-center gap-2 rounded-lg px-2 py-1 text-xs"
|
||||
:class="editChainPartClass(index)"
|
||||
>
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :class="editChainPartDot(index)" />
|
||||
<span class="shrink-0 font-medium">{{ part.label }}</span>
|
||||
<span class="min-w-0 truncate text-zinc-400">{{ part.prompt }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: editProgress + '%' }" />
|
||||
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: (editOverallProgress || editProgress) + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -743,7 +793,10 @@
|
||||
|
||||
<div class="glass rounded-3xl p-5 sm:p-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="font-display text-xl font-bold">Library</h2>
|
||||
<div>
|
||||
<h2 class="font-display text-xl font-bold">Library</h2>
|
||||
<p class="mt-1 text-xs text-zinc-500">Generated outputs only. Uploaded stills live in the image picker.</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="browseFolder?.protected && browseFolder.unlocked"
|
||||
type="button"
|
||||
@@ -755,7 +808,7 @@
|
||||
</div>
|
||||
|
||||
<form v-if="browseFolder?.protected && !browseFolder.unlocked" class="mt-4 space-y-3" @submit.prevent="unlockFolder(browseFolderId)">
|
||||
<p class="text-sm text-zinc-400">This folder is locked. Unlock to view {{ browseFolder.clipCount || 0 }} clip{{ browseFolder.clipCount === 1 ? '' : 's' }}.</p>
|
||||
<p class="text-sm text-zinc-400">This folder is locked. Unlock to view {{ folderOutputCount(browseFolder) }} output{{ folderOutputCount(browseFolder) === 1 ? '' : 's' }}.</p>
|
||||
<input v-model="unlockPassword" 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="w-full rounded-xl bg-amber-400 px-4 py-2 font-semibold text-zinc-950">Unlock</button>
|
||||
</form>
|
||||
@@ -771,25 +824,39 @@
|
||||
@click="browseFolderId = folder.id"
|
||||
>
|
||||
{{ folder.name }}
|
||||
<span class="text-zinc-500">({{ folder.unlocked ? clipsIn(folder.id).length : folder.clipCount }})</span>
|
||||
<span class="text-zinc-500">({{ folderOutputCount(folder) }})</span>
|
||||
<span v-if="folder.protected">{{ folder.unlocked ? '' : ' · lock' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!libraryGroups.length" class="mt-4 text-sm text-zinc-500">Nothing in this folder yet. Generate into it from the left.</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="option in [{ id: 'all', label: 'All' }, { id: 'images', label: 'Images' }, { id: 'videos', label: 'Videos' }] as const"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs"
|
||||
:class="libraryFilter === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
|
||||
@click="libraryFilter = option.id"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!libraryEntries.length" class="mt-4 text-sm text-zinc-500">{{ libraryEmptyLabel }}</p>
|
||||
<ul class="mt-4 space-y-3">
|
||||
<li
|
||||
v-for="group in libraryGroups"
|
||||
:key="group.key"
|
||||
v-for="entry in libraryEntries"
|
||||
:key="entry.key"
|
||||
class="rounded-2xl border border-white/5 bg-zinc-950/40 p-3"
|
||||
>
|
||||
<template v-if="entry.kind === 'clip'">
|
||||
<div class="flex gap-3">
|
||||
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openClip(group.head)">
|
||||
<span v-if="group.head.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
||||
<img v-else-if="group.head.hasThumbnail" :src="`/api/library/clips/${group.head.id}/thumb`" alt="" class="h-full w-full object-cover">
|
||||
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openClip(entry.group.head)">
|
||||
<span v-if="entry.group.head.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
||||
<img v-else-if="entry.group.head.hasThumbnail" :src="`/api/library/clips/${entry.group.head.id}/thumb`" alt="" class="h-full w-full object-cover">
|
||||
<video
|
||||
v-else
|
||||
:src="`/api/library/clips/${group.head.id}/video#t=0.1`"
|
||||
:src="`/api/library/clips/${entry.group.head.id}/video#t=0.1`"
|
||||
class="h-full w-full object-cover"
|
||||
muted
|
||||
playsinline
|
||||
@@ -799,23 +866,23 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<input
|
||||
v-if="renamingId === group.head.id && renamingFamily"
|
||||
v-if="renamingId === entry.group.head.id && renamingFamily"
|
||||
ref="renameInput"
|
||||
v-model="renameDraft"
|
||||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||
maxlength="80"
|
||||
@keydown.enter.prevent="saveRename(group.head, true)"
|
||||
@keydown.enter.prevent="saveRename(entry.group.head, true)"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="saveRename(group.head, true)"
|
||||
@blur="saveRename(entry.group.head, true)"
|
||||
>
|
||||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ collectionTitle(group) }}</p>
|
||||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ collectionTitle(entry.group) }}</p>
|
||||
<div class="group relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
|
||||
:aria-label="'Copy prompt for ' + collectionTitle(group)"
|
||||
:aria-label="'Copy prompt for ' + collectionTitle(entry.group)"
|
||||
title="Copy prompt"
|
||||
@click.stop="copyPrompt(familyScript(group.parts))"
|
||||
@click.stop="copyPrompt(familyScript(entry.group.parts))"
|
||||
>
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
@@ -823,41 +890,41 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div class="pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible">
|
||||
{{ familyScript(group.parts) }}
|
||||
{{ familyScript(entry.group.parts) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500">{{ clipMetaLine(group.head) }}</p>
|
||||
<p class="text-xs text-zinc-500">{{ clipMetaLine(entry.group.head) }}</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||
<button v-if="group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(group.key)">
|
||||
{{ expandedFamilies[group.key] ? 'Collapse' : `Expand ${group.parts.length} parts` }}
|
||||
<button v-if="entry.group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.group.key)">
|
||||
{{ expandedFamilies[entry.group.key] ? 'Collapse' : `Expand ${entry.group.parts.length} parts` }}
|
||||
</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="openClip(group.head)">Open</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="openExtend(group.head)">Extend</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="startRename(group.head, true)">Rename</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="rerun(group.head, true)">Rerun</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="openClip(entry.group.head)">Open</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="openExtend(entry.group.head)">Extend</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="startRename(entry.group.head, true)">Rename</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="rerun(entry.group.head, true)">Rerun</button>
|
||||
<select
|
||||
v-if="folders.length > 1"
|
||||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||||
:value="''"
|
||||
@change="onMoveClip(group.head, $event, true)"
|
||||
@change="onMoveClip(entry.group.head, $event, true)"
|
||||
>
|
||||
<option disabled value="">Move to…</option>
|
||||
<option
|
||||
v-for="folder in folders.filter(folder => folder.id !== group.head.folderId)"
|
||||
v-for="folder in folders.filter(folder => folder.id !== entry.group.head.folderId)"
|
||||
:key="folder.id"
|
||||
:value="folder.id"
|
||||
>
|
||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="text-xs text-red-300" @click="removeClip(group.head, true)">Delete</button>
|
||||
<button type="button" class="text-xs text-red-300" @click="removeClip(entry.group.head, true)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="expandedFamilies[group.key] && group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
|
||||
<ul v-if="expandedFamilies[entry.group.key] && entry.group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
|
||||
<li
|
||||
v-for="item in chronologicalParts(group.parts)"
|
||||
v-for="item in chronologicalParts(entry.group.parts)"
|
||||
:key="item.id"
|
||||
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
|
||||
>
|
||||
@@ -912,6 +979,130 @@
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else-if="entry.kind === 'still' && entry.stillGroup">
|
||||
<div class="flex gap-3">
|
||||
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openStill(entry.stillGroup.head)">
|
||||
<span v-if="entry.stillGroup.head.hideInput" class="sensitive-glow block h-full w-full" />
|
||||
<img
|
||||
v-else
|
||||
:src="`/api/library/stills/${entry.stillGroup.head.id}`"
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<input
|
||||
v-if="renamingId === entry.stillGroup.head.id && renamingFamily"
|
||||
v-model="renameDraft"
|
||||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||
maxlength="80"
|
||||
@keydown.enter.prevent="saveRenameStill(entry.stillGroup.head, true)"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="saveRenameStill(entry.stillGroup.head, true)"
|
||||
>
|
||||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillCollectionTitle(entry.stillGroup) }}</p>
|
||||
<div class="group relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
|
||||
:aria-label="'Copy prompt for ' + stillCollectionTitle(entry.stillGroup)"
|
||||
title="Copy prompt"
|
||||
@click.stop="copyPrompt(stillFamilyScript(entry.stillGroup.parts))"
|
||||
>
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4M12 8h.01" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible">
|
||||
{{ stillFamilyScript(entry.stillGroup.parts) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500">{{ stillMetaLine(entry.stillGroup.head) }}</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||
<button v-if="entry.stillGroup.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.stillGroup.key)">
|
||||
{{ expandedFamilies[entry.stillGroup.key] ? 'Collapse' : `Expand ${entry.stillGroup.parts.length} passes` }}
|
||||
</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="openStill(entry.stillGroup.head)">Open</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(entry.stillGroup.head)">Use as input</button>
|
||||
<a
|
||||
:href="`/api/library/stills/${entry.stillGroup.head.id}`"
|
||||
:download="stillTitle(entry.stillGroup.head)"
|
||||
class="text-xs text-amber-300"
|
||||
>Download</a>
|
||||
<button type="button" class="text-xs text-amber-300" @click="startRenameStill(entry.stillGroup.head, true)">Rename</button>
|
||||
<select
|
||||
v-if="folders.length > 1"
|
||||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||||
:value="''"
|
||||
@change="onMoveStill(entry.stillGroup.head, $event, true)"
|
||||
>
|
||||
<option disabled value="">Move to…</option>
|
||||
<option
|
||||
v-for="folder in folders.filter(folder => folder.id !== entry.stillGroup.head.folderId)"
|
||||
:key="folder.id"
|
||||
:value="folder.id"
|
||||
>
|
||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="text-xs text-red-300" @click="removeStill(entry.stillGroup.head, true)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="expandedFamilies[entry.stillGroup.key] && entry.stillGroup.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
|
||||
<li
|
||||
v-for="item in chronologicalStills(entry.stillGroup.parts)"
|
||||
:key="item.id"
|
||||
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
|
||||
>
|
||||
<button type="button" class="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-zinc-900" @click="openStill(item)">
|
||||
<span v-if="item.hideInput" class="sensitive-glow block h-full w-full" />
|
||||
<img v-else :src="`/api/library/stills/${item.id}`" alt="" class="h-full w-full object-cover">
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<input
|
||||
v-if="renamingId === item.id && !renamingFamily"
|
||||
v-model="renameDraft"
|
||||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||
maxlength="80"
|
||||
@keydown.enter.prevent="saveRenameStill(item)"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="saveRenameStill(item)"
|
||||
>
|
||||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillTitle(item) }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500">{{ stillMetaLine(item) }}</p>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-3">
|
||||
<button type="button" class="text-xs text-amber-300" @click="openStill(item)">Open</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(item)">Use as input</button>
|
||||
<a :href="`/api/library/stills/${item.id}`" :download="stillTitle(item)" class="text-xs text-amber-300">Download</a>
|
||||
<button type="button" class="text-xs text-amber-300" @click="startRenameStill(item)">Rename</button>
|
||||
<select
|
||||
v-if="folders.length > 1"
|
||||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||||
:value="''"
|
||||
@change="onMoveStill(item, $event)"
|
||||
>
|
||||
<option disabled value="">Move to…</option>
|
||||
<option
|
||||
v-for="folder in folders.filter(folder => folder.id !== item.folderId)"
|
||||
:key="folder.id"
|
||||
:value="folder.id"
|
||||
>
|
||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="text-xs text-red-300" @click="removeStill(item)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -967,6 +1158,7 @@ interface LibraryFolder {
|
||||
protected: boolean
|
||||
unlocked: boolean
|
||||
clipCount?: number
|
||||
imageCount?: number
|
||||
}
|
||||
|
||||
interface LibraryStill {
|
||||
@@ -977,6 +1169,12 @@ interface LibraryStill {
|
||||
height: number
|
||||
createdAt: number
|
||||
hideInput?: boolean
|
||||
role?: 'input' | 'output'
|
||||
name?: string
|
||||
prompt?: string
|
||||
familyId?: string
|
||||
parentStillId?: string
|
||||
chainIndex?: number
|
||||
}
|
||||
|
||||
interface LibraryClip {
|
||||
@@ -1040,6 +1238,11 @@ interface QueuedExtension {
|
||||
duration: number
|
||||
}
|
||||
|
||||
interface QueuedEditPass {
|
||||
id: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
|
||||
'16:9': { width: 1344, height: 768 },
|
||||
'9:16': { width: 768, height: 1344 },
|
||||
@@ -1061,6 +1264,7 @@ const dragging = ref(false)
|
||||
const prompt = ref('')
|
||||
const shotScriptMode = ref(false)
|
||||
const extensionQueue = ref<QueuedExtension[]>([])
|
||||
const editPassQueue = ref<QueuedEditPass[]>([])
|
||||
const clipName = ref('')
|
||||
const videoDownloadName = ref('aigen')
|
||||
const editDownloadName = ref('aigen-edit')
|
||||
@@ -1128,6 +1332,12 @@ const editCfg = ref(1)
|
||||
const editNegative = ref('')
|
||||
const editRefFile = ref<File | null>(null)
|
||||
const editRefPreview = ref('')
|
||||
const editChainStep = ref(1)
|
||||
const editChainTotal = ref(1)
|
||||
const editChainLabel = ref('')
|
||||
const editOverallProgress = ref(0)
|
||||
const editCompletedChainStep = ref(0)
|
||||
const editActiveChainPlan = ref<{ label: string; prompt: string }[]>([])
|
||||
const videoJobId = ref('')
|
||||
const editJobId = ref('')
|
||||
const currentClipId = ref('')
|
||||
@@ -1165,6 +1375,7 @@ const unlockPassword = ref('')
|
||||
const settingsOpen = ref(false)
|
||||
const pickerOpen = ref(false)
|
||||
const pickerSlot = ref<'main' | 'editRef' | number>('main')
|
||||
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
|
||||
const videoWorkflow = ref('v1')
|
||||
const useIdentityRefs = ref(false)
|
||||
const identityRefs = ref<(File | null)[]>([null, null, null, null])
|
||||
@@ -1305,6 +1516,7 @@ const videoGenerateDisabled = computed(() => {
|
||||
const editBlocked = computed(() => {
|
||||
if (editBusy.value) return true
|
||||
if (!file.value || !prompt.value.trim() || !folderId.value) return true
|
||||
if (editPassQueue.value.some(item => !item.prompt.trim())) return true
|
||||
if (!comfyOk.value && !imageComfyOk.value) return true
|
||||
return false
|
||||
})
|
||||
@@ -1319,6 +1531,11 @@ const generateLabel = computed(() => {
|
||||
if (!n) return 'Generate Video'
|
||||
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
|
||||
})
|
||||
const editLabel = computed(() => {
|
||||
const n = editPassQueue.value.length
|
||||
if (!n) return 'Edit image'
|
||||
return n === 1 ? 'Edit image + 1 pass' : `Edit image + ${n} passes`
|
||||
})
|
||||
const parsedShots = computed(() => parseShotScript(prompt.value))
|
||||
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value)
|
||||
const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : []))
|
||||
@@ -1364,6 +1581,22 @@ const plannedChain = computed(() => {
|
||||
})
|
||||
|
||||
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value && clip.hasVideo !== false))
|
||||
const visibleOutputStills = computed(() => stills.value.filter(still => still.folderId === browseFolderId.value && still.role === 'output'))
|
||||
const stillGroups = computed(() => {
|
||||
const groups = new Map<string, LibraryStill[]>()
|
||||
for (const still of visibleOutputStills.value) {
|
||||
const key = stillFamilyKey(still)
|
||||
const list = groups.get(key) || []
|
||||
list.push(still)
|
||||
groups.set(key, list)
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.map(([key, parts]) => {
|
||||
const sorted = [...parts].sort(sortStillParts)
|
||||
return { key, head: sorted[0], parts: sorted }
|
||||
})
|
||||
.sort((a, b) => b.head.createdAt - a.head.createdAt)
|
||||
})
|
||||
const libraryGroups = computed(() => {
|
||||
const groups = new Map<string, LibraryClip[]>()
|
||||
for (const clip of visibleClips.value) {
|
||||
@@ -1379,13 +1612,50 @@ const libraryGroups = computed(() => {
|
||||
})
|
||||
.sort((a, b) => b.head.createdAt - a.head.createdAt)
|
||||
})
|
||||
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
|
||||
const libraryEntries = computed(() => {
|
||||
const videos = libraryFilter.value === 'images'
|
||||
? []
|
||||
: libraryGroups.value.map(group => ({
|
||||
kind: 'clip' as const,
|
||||
key: group.key,
|
||||
createdAt: group.head.createdAt,
|
||||
group
|
||||
}))
|
||||
const images = libraryFilter.value === 'videos'
|
||||
? []
|
||||
: stillGroups.value.map(group => ({
|
||||
kind: 'still' as const,
|
||||
key: group.key,
|
||||
createdAt: group.head.createdAt,
|
||||
stillGroup: group
|
||||
}))
|
||||
return [...videos, ...images].sort((a, b) => b.createdAt - a.createdAt)
|
||||
})
|
||||
const libraryEmptyLabel = computed(() => {
|
||||
if (libraryFilter.value === 'images') return 'No generated stills in this folder yet.'
|
||||
if (libraryFilter.value === 'videos') return 'No generated clips in this folder yet.'
|
||||
return 'Nothing in this folder yet. Generate into it from the left.'
|
||||
})
|
||||
const browseFolder = computed(() => folders.value.find(folder => folder.id === browseFolderId.value))
|
||||
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
|
||||
|
||||
function clipsIn(id: string) {
|
||||
return clips.value.filter(clip => clip.folderId === id)
|
||||
}
|
||||
|
||||
function stillsIn(id: string) {
|
||||
return stills.value.filter(still => still.folderId === id && still.role === 'output')
|
||||
}
|
||||
|
||||
function folderOutputCount(folder: LibraryFolder) {
|
||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
|
||||
const videos = clipsIn(folder.id).length
|
||||
const images = stillsIn(folder.id).length
|
||||
if (libraryFilter.value === 'videos') return videos
|
||||
if (libraryFilter.value === 'images') return images
|
||||
return videos + images
|
||||
}
|
||||
|
||||
function familyBaseName(name: string) {
|
||||
return String(name || '').trim().replace(/\s+p\d+$/i, '')
|
||||
}
|
||||
@@ -1397,6 +1667,25 @@ function clipFamilyKey(clip: LibraryClip) {
|
||||
return `solo:${clip.id}`
|
||||
}
|
||||
|
||||
function stillFamilyKey(still: LibraryStill) {
|
||||
if (still.familyId) return `still-family:${still.folderId}:${still.familyId}`
|
||||
return `still-solo:${still.id}`
|
||||
}
|
||||
|
||||
function sortStillParts(a: LibraryStill, b: LibraryStill) {
|
||||
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
|
||||
if (chainDelta) return chainDelta
|
||||
return b.createdAt - a.createdAt
|
||||
}
|
||||
|
||||
function chronologicalStills(parts: LibraryStill[]) {
|
||||
return [...parts].sort((a, b) => {
|
||||
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
|
||||
if (chain) return chain
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
}
|
||||
|
||||
function sortClipParts(a: LibraryClip, b: LibraryClip) {
|
||||
const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0)
|
||||
if (durationDelta) return durationDelta
|
||||
@@ -1440,6 +1729,31 @@ function clipMetaLine(item: LibraryClip) {
|
||||
return duration ? `${res} · ${duration} · ${steps} · ${seed}` : `${res} · ${steps} · ${seed}`
|
||||
}
|
||||
|
||||
function stillTitle(still: LibraryStill) {
|
||||
return still.name || still.filename.replace(/\.[a-z0-9]+$/i, '') || 'Edited still'
|
||||
}
|
||||
|
||||
function stillCollectionTitle(group: { head: LibraryStill; parts: LibraryStill[] }) {
|
||||
const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name)
|
||||
return named || stillTitle(group.head)
|
||||
}
|
||||
|
||||
function stillFamilyScript(parts: LibraryStill[]) {
|
||||
const ordered = chronologicalStills(parts)
|
||||
if (ordered.length <= 1) return ordered[0]?.prompt || ''
|
||||
return ordered.map((part, index) => (
|
||||
index === 0 ? (part.prompt || '').trim() : `pass ${index + 1}\n${(part.prompt || '').trim()}`
|
||||
)).join('\n\n')
|
||||
}
|
||||
|
||||
function stillMetaLine(still: LibraryStill) {
|
||||
const res = still.width && still.height ? `${still.width}×${still.height}` : 'image'
|
||||
const pass = typeof still.chainIndex === 'number' ? `pass ${still.chainIndex + 1}` : ''
|
||||
if (pass && still.prompt) return `${res} · ${pass} · ${still.prompt}`
|
||||
if (still.prompt) return `${res} · ${still.prompt}`
|
||||
return pass ? `${res} · ${pass}` : res
|
||||
}
|
||||
|
||||
function toggleFamily(key: string) {
|
||||
expandedFamilies.value = { ...expandedFamilies.value, [key]: !expandedFamilies.value[key] }
|
||||
}
|
||||
@@ -1464,6 +1778,26 @@ function chainPartDot(index: number) {
|
||||
return 'bg-zinc-600'
|
||||
}
|
||||
|
||||
function editChainPartDone(index: number) {
|
||||
return index < editCompletedChainStep.value
|
||||
}
|
||||
|
||||
function editChainPartCurrent(index: number) {
|
||||
return editBusy.value && index === editCompletedChainStep.value
|
||||
}
|
||||
|
||||
function editChainPartClass(index: number) {
|
||||
if (editChainPartDone(index)) return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
|
||||
if (editChainPartCurrent(index)) return 'border border-amber-300/40 bg-amber-400/10 text-amber-100'
|
||||
return 'border border-white/5 text-zinc-400'
|
||||
}
|
||||
|
||||
function editChainPartDot(index: number) {
|
||||
if (editChainPartDone(index)) return 'bg-emerald-400'
|
||||
if (editChainPartCurrent(index)) return 'bg-amber-300 animate-pulse'
|
||||
return 'bg-zinc-600'
|
||||
}
|
||||
|
||||
function applyThumbnailDefault() {
|
||||
if (!showPrivacyToggles.value) {
|
||||
hideThumbnail.value = false
|
||||
@@ -2177,6 +2511,7 @@ async function generateExtension() {
|
||||
}
|
||||
|
||||
function openClip(item: LibraryClip) {
|
||||
studioMode.value = 'video'
|
||||
closeExtend()
|
||||
watchingLibrary.value = true
|
||||
currentClipId.value = item.id
|
||||
@@ -2193,6 +2528,117 @@ function openClip(item: LibraryClip) {
|
||||
}
|
||||
}
|
||||
|
||||
function openStill(still: LibraryStill) {
|
||||
studioMode.value = 'edit'
|
||||
browseFolderId.value = still.folderId
|
||||
editResultUrl.value = `/api/library/stills/${still.id}`
|
||||
editAwaitingReveal.value = still.hideInput === true
|
||||
editLockedSave.value = false
|
||||
editDownloadName.value = clipFileName(stillTitle(still))
|
||||
if (!editBusy.value) {
|
||||
editStatusMessage.value = still.hideInput ? 'Hidden still ready' : 'Loaded from library'
|
||||
editProgress.value = 100
|
||||
}
|
||||
}
|
||||
|
||||
async function useStillAsInput(still: LibraryStill) {
|
||||
try {
|
||||
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
||||
if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
||||
readFile(new File([blob], still.filename, { type: blob.type || 'image/png' }))
|
||||
toast('Loaded as input. It stays in Output, not the upload library.')
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Could not load that still')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStill(still: LibraryStill, family = false) {
|
||||
const watched = family ? stillFamilyPartsFor(still) : [still]
|
||||
const label = family && watched.length > 1
|
||||
? `this ${watched.length}-pass edit`
|
||||
: `“${stillTitle(still)}”`
|
||||
if (!confirm(`Remove ${label} from this folder?`)) return
|
||||
try {
|
||||
await $fetch(`/api/library/stills/${still.id}`, {
|
||||
method: 'DELETE',
|
||||
query: family ? { family: '1' } : undefined
|
||||
})
|
||||
const ids = new Set(watched.map(item => item.id))
|
||||
if ([...ids].some(id => editResultUrl.value.includes(id))) {
|
||||
editResultUrl.value = ''
|
||||
editAwaitingReveal.value = false
|
||||
editLockedSave.value = false
|
||||
}
|
||||
await loadLibrary()
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Could not delete image')
|
||||
}
|
||||
}
|
||||
|
||||
async function onMoveStill(still: LibraryStill, event: Event, family = false) {
|
||||
const select = event.target as HTMLSelectElement
|
||||
const destId = select.value
|
||||
select.value = ''
|
||||
if (!destId || destId === still.folderId) return
|
||||
try {
|
||||
applyLibrary(await $fetch(`/api/library/stills/${still.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { folderId: destId, family: family || undefined }
|
||||
}))
|
||||
const dest = folders.value.find(folder => folder.id === destId)
|
||||
if (dest?.unlocked) browseFolderId.value = destId
|
||||
editStatusMessage.value = dest?.unlocked
|
||||
? `Moved ${family ? 'edit chain' : 'still'} to ${dest.name}`
|
||||
: `Moved ${family ? 'edit chain' : 'still'} to ${dest?.name || 'folder'} (locked)`
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Could not move image')
|
||||
}
|
||||
}
|
||||
|
||||
function startRenameStill(still: LibraryStill, family = false) {
|
||||
renamingId.value = still.id
|
||||
renamingFamily.value = family
|
||||
const source = family
|
||||
? (familyBaseName(still.name || '') || still.name || '')
|
||||
: (still.name || stillTitle(still))
|
||||
renameDraft.value = source.trim() || 'edited'
|
||||
nextTick(() => {
|
||||
renameInput.value?.focus()
|
||||
renameInput.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
async function saveRenameStill(still: LibraryStill, family = false) {
|
||||
if (renamingId.value !== still.id || renamingFamily.value !== family) return
|
||||
const name = renameDraft.value.trim().slice(0, 80)
|
||||
if (!name) {
|
||||
toast('A name is required')
|
||||
return
|
||||
}
|
||||
const current = family ? familyBaseName(still.name || '') : still.name
|
||||
if (name === current) {
|
||||
cancelRename()
|
||||
return
|
||||
}
|
||||
try {
|
||||
applyLibrary(await $fetch(`/api/library/stills/${still.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { name, family: family || undefined }
|
||||
}))
|
||||
cancelRename()
|
||||
if (editResultUrl.value.includes(still.id)) {
|
||||
editDownloadName.value = clipFileName(name)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Could not rename image')
|
||||
}
|
||||
}
|
||||
|
||||
function stillFamilyPartsFor(still: LibraryStill) {
|
||||
const group = stillGroups.value.find(entry => entry.parts.some(part => part.id === still.id))
|
||||
return chronologicalStills(group?.parts || [still])
|
||||
}
|
||||
|
||||
async function removeClip(item: LibraryClip, family = false) {
|
||||
const watched = family ? familyPartsFor(item) : [item]
|
||||
try {
|
||||
@@ -2305,12 +2751,28 @@ function removeQueuedExtension(id: string) {
|
||||
extensionQueue.value = extensionQueue.value.filter(item => item.id !== id)
|
||||
}
|
||||
|
||||
function queueEditPass() {
|
||||
editPassQueue.value.push({
|
||||
id: crypto.randomUUID(),
|
||||
prompt: ''
|
||||
})
|
||||
}
|
||||
|
||||
function removeEditPass(id: string) {
|
||||
editPassQueue.value = editPassQueue.value.filter(item => item.id !== id)
|
||||
}
|
||||
|
||||
async function editImage() {
|
||||
if (!file.value || !prompt.value.trim()) return
|
||||
if (editPassQueue.value.some(item => !item.prompt.trim())) {
|
||||
toast('Each extra pass needs a prompt.')
|
||||
return
|
||||
}
|
||||
if (!folderId.value) {
|
||||
toast('Choose a library folder before editing.')
|
||||
return
|
||||
}
|
||||
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
|
||||
editStatusBusy.value = false
|
||||
editBusy.value = true
|
||||
stopListen('edit')
|
||||
@@ -2321,6 +2783,15 @@ async function editImage() {
|
||||
editLockedSave.value = false
|
||||
editStatusMessage.value = 'Checking Beast ComfyUI...'
|
||||
editMaxStep.value = editSteps.value
|
||||
editChainStep.value = 1
|
||||
editChainTotal.value = 1 + extraPasses.length
|
||||
editChainLabel.value = extraPasses.length ? 'Pass 1' : ''
|
||||
editOverallProgress.value = 0
|
||||
editCompletedChainStep.value = 0
|
||||
editActiveChainPlan.value = [
|
||||
{ label: 'Pass 1', prompt: prompt.value.trim() },
|
||||
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
|
||||
]
|
||||
startTimer('edit')
|
||||
try {
|
||||
const body = new FormData()
|
||||
@@ -2334,6 +2805,7 @@ async function editImage() {
|
||||
body.append('folderId', folderId.value)
|
||||
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
|
||||
body.append('name', clipName.value.trim())
|
||||
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
|
||||
editDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit')
|
||||
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/edit', {
|
||||
method: 'POST',
|
||||
@@ -2482,17 +2954,55 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
|
||||
if (typeof payload.progress === 'number') editProgress.value = payload.progress
|
||||
if (typeof payload.step === 'number') editStep.value = payload.step
|
||||
if (typeof payload.maxStep === 'number') editMaxStep.value = payload.maxStep
|
||||
if (typeof payload.chainStep === 'number') editChainStep.value = payload.chainStep
|
||||
if (typeof payload.chainTotal === 'number') editChainTotal.value = payload.chainTotal
|
||||
if (payload.chainLabel) editChainLabel.value = String(payload.chainLabel)
|
||||
if (typeof payload.overallProgress === 'number') editOverallProgress.value = payload.overallProgress
|
||||
|
||||
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
|
||||
const complete = payload.type === 'complete' || payload.status === 'complete'
|
||||
if (editSettledUi) return
|
||||
|
||||
const dest = folders.value.find(folder => folder.id === folderId.value)
|
||||
const locked = dest
|
||||
? Boolean(dest.protected && !dest.unlocked)
|
||||
: (folderLocked || payload.folderLocked === true)
|
||||
|
||||
if (payload.stillId && !locked) {
|
||||
editResultUrl.value = `/api/library/stills/${payload.stillId}`
|
||||
editLockedSave.value = false
|
||||
if (payload.hideThumbnail || hidden) editAwaitingReveal.value = payload.type !== 'checkpoint'
|
||||
}
|
||||
|
||||
if (payload.type === 'checkpoint' && payload.stillId) {
|
||||
editCompletedChainStep.value = Math.max(editCompletedChainStep.value, Number(payload.chainStep || editChainStep.value) || 0)
|
||||
loadLibrary().catch(() => null)
|
||||
return
|
||||
}
|
||||
|
||||
if (complete && payload.stillId) {
|
||||
const total = Math.max(1, Number(payload.chainTotal || 0) || 0, Number(editChainTotal.value || 0) || 0)
|
||||
const step = Math.max(1, Number(payload.chainStep || 0) || 0, Number(editChainStep.value || 0) || 0)
|
||||
if (total > 1 && step < total) {
|
||||
editCompletedChainStep.value = Math.max(editCompletedChainStep.value, step)
|
||||
loadLibrary().catch(() => null)
|
||||
if (locked) {
|
||||
editResultUrl.value = ''
|
||||
editLockedSave.value = true
|
||||
}
|
||||
editStatusMessage.value = `Pass ${step} of ${total} saved, but the remaining passes did not start.`
|
||||
editProgress.value = Math.min(Number(payload.progress || editProgress.value || 99), 99)
|
||||
toast(`Pass ${step} of ${total} saved. Remaining passes did not run.`)
|
||||
editBusy.value = false
|
||||
editStatusBusy.value = false
|
||||
stopTimer('edit')
|
||||
stopListen('edit')
|
||||
clearActiveJob('edit')
|
||||
void pollComfyHealth()
|
||||
editSettledUi = true
|
||||
return
|
||||
}
|
||||
editSettledUi = true
|
||||
const dest = folders.value.find(folder => folder.id === folderId.value)
|
||||
const locked = dest
|
||||
? Boolean(dest.protected && !dest.unlocked)
|
||||
: (folderLocked || payload.folderLocked === true)
|
||||
if (locked) {
|
||||
editResultUrl.value = ''
|
||||
editLockedSave.value = true
|
||||
@@ -2502,7 +3012,8 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
|
||||
editResultUrl.value = `/api/library/stills/${payload.stillId}`
|
||||
editAwaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
||||
editLockedSave.value = false
|
||||
editStatusMessage.value = 'Edit finished'
|
||||
editStatusMessage.value = total > 1 ? 'Edit chain finished' : 'Edit finished'
|
||||
editCompletedChainStep.value = total
|
||||
}
|
||||
editBusy.value = false
|
||||
editStatusBusy.value = false
|
||||
|
||||
+198
-106
@@ -16,6 +16,19 @@ function parseCfg(raw: unknown) {
|
||||
return Math.min(8, Math.max(0.5, Math.round(value * 10) / 10))
|
||||
}
|
||||
|
||||
function parsePasses(raw: string | undefined) {
|
||||
if (!raw) return [] as { prompt: string }[]
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.map((item: { prompt?: unknown }) => ({ prompt: String(item?.prompt || '').trim() }))
|
||||
.filter(item => item.prompt)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const form = await readMultipartFormData(event).catch(() => null)
|
||||
const fields: Record<string, string> = {}
|
||||
@@ -32,12 +45,16 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const prompt = (fields.prompt || '').trim()
|
||||
const extraPasses = parsePasses(fields.passes)
|
||||
if (!image) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
|
||||
}
|
||||
if (!prompt) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
|
||||
}
|
||||
if (extraPasses.some(item => !item.prompt)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Each extra pass needs a prompt' })
|
||||
}
|
||||
if (!comfyConfigured()) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
@@ -65,19 +82,17 @@ export default defineEventHandler(async (event) => {
|
||||
: Math.floor(Math.random() * 2_147_483_647)
|
||||
const size = imageDimensions(image.data)
|
||||
|
||||
const still = isPipelineFrameFilename(image.filename)
|
||||
? null
|
||||
: await saveStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput
|
||||
})
|
||||
if (reference && !isPipelineFrameFilename(reference.filename)) {
|
||||
await saveStill({
|
||||
const still = await rememberInputStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput
|
||||
})
|
||||
if (reference) {
|
||||
await rememberInputStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: reference.filename,
|
||||
@@ -86,6 +101,9 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const passes = extraPasses
|
||||
const chainTotal = 1 + passes.length
|
||||
const familyId = chainTotal > 1 ? crypto.randomUUID() : undefined
|
||||
const job = createJob()
|
||||
job.kind = 'edit'
|
||||
job.maxStep = steps
|
||||
@@ -105,14 +123,28 @@ export default defineEventHandler(async (event) => {
|
||||
turbo: true,
|
||||
seed,
|
||||
cfg,
|
||||
stillId: still?.id
|
||||
stillId: still?.id,
|
||||
familyId,
|
||||
chainIndex: 0,
|
||||
chainStep: 1,
|
||||
chainTotal,
|
||||
chainLabel: chainTotal > 1 ? 'Pass 1' : undefined,
|
||||
passes
|
||||
}
|
||||
emitJob(job, { type: 'status', message: 'Checking Beast ComfyUI...', progress: 2 })
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: 'Checking Beast ComfyUI...',
|
||||
progress: 2,
|
||||
chainStep: 1,
|
||||
chainTotal,
|
||||
chainLabel: job.library.chainLabel
|
||||
})
|
||||
|
||||
void runEdit(job, {
|
||||
image,
|
||||
reference,
|
||||
prompt,
|
||||
passes,
|
||||
negative: (fields.negative || '').trim(),
|
||||
steps,
|
||||
seed,
|
||||
@@ -131,7 +163,8 @@ export default defineEventHandler(async (event) => {
|
||||
steps,
|
||||
hideThumbnail: hideInput,
|
||||
folderLocked,
|
||||
dual: Boolean(reference)
|
||||
dual: Boolean(reference),
|
||||
chainTotal
|
||||
}
|
||||
})
|
||||
|
||||
@@ -141,14 +174,22 @@ async function runEdit(
|
||||
image: { filename: string; data: Buffer; type?: string }
|
||||
reference: { filename: string; data: Buffer; type?: string } | null
|
||||
prompt: string
|
||||
passes: { prompt: string }[]
|
||||
negative: string
|
||||
steps: number
|
||||
seed: number
|
||||
cfg: number
|
||||
}
|
||||
) {
|
||||
const library = job.library
|
||||
if (!library) throw new Error('Edit job is missing library metadata')
|
||||
const prompts = [params.prompt, ...params.passes.map(item => item.prompt)]
|
||||
const chainTotal = prompts.length
|
||||
library.chainTotal = chainTotal
|
||||
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
|
||||
|
||||
await ensureComfyReady((status) => {
|
||||
emitJob(job, {
|
||||
emitChainJob(job, {
|
||||
type: status.state === 'busy' ? 'busy' : 'status',
|
||||
message: status.message,
|
||||
progress: status.state === 'online' ? Math.max(job.progress, 6) : Math.max(job.progress, 3),
|
||||
@@ -159,96 +200,147 @@ async function runEdit(
|
||||
job.imageComfyHost = getComfyHost()
|
||||
|
||||
await withImageComfyHost(job.imageComfyHost, async () => {
|
||||
const dual = Boolean(params.reference)
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...',
|
||||
progress: 8
|
||||
})
|
||||
const uploaded = await uploadImage(params.image, job.id)
|
||||
const uploadedRef = params.reference
|
||||
? await uploadImage({
|
||||
...params.reference,
|
||||
filename: `ref_${params.reference.filename || 'image2.png'}`
|
||||
}, job.id)
|
||||
: null
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: dual
|
||||
? 'Queueing Flux.2 Klein two-image edit on Beast...'
|
||||
: 'Queueing Flux.2 Klein edit on Beast...',
|
||||
progress: 12
|
||||
})
|
||||
const graph = buildEditWorkflow({
|
||||
prompt: params.prompt,
|
||||
negative: params.negative,
|
||||
imageName: uploaded.name,
|
||||
referenceImageName: uploadedRef?.name,
|
||||
steps: params.steps,
|
||||
seed: params.seed,
|
||||
cfg: params.cfg,
|
||||
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}`
|
||||
})
|
||||
const queued = await queuePrompt(graph, job.clientId)
|
||||
job.promptId = queued.prompt_id
|
||||
job.status = 'running'
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: dual ? 'Applying image 2 onto image 1...' : 'Editing still on Beast...',
|
||||
progress: 18,
|
||||
maxStep: params.steps
|
||||
})
|
||||
|
||||
const output = await waitForImageEdit({
|
||||
promptId: queued.prompt_id,
|
||||
clientId: job.clientId,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
onProgress: (event) => {
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: event.message,
|
||||
progress: event.progress,
|
||||
step: event.step,
|
||||
maxStep: event.maxStep || params.steps,
|
||||
node: event.node
|
||||
let current = params.image
|
||||
let parentStillId: string | undefined
|
||||
for (let index = 0; index < prompts.length; index++) {
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
const prompt = prompts[index]
|
||||
const last = index === prompts.length - 1
|
||||
if (index > 0) {
|
||||
await ensureComfyReady((status) => {
|
||||
emitChainJob(job, {
|
||||
type: status.state === 'busy' ? 'busy' : 'status',
|
||||
message: status.message,
|
||||
progress: status.state === 'online' ? 4 : 2,
|
||||
busy: status.state === 'busy'
|
||||
})
|
||||
})
|
||||
},
|
||||
isCancelled: () => job.status === 'cancelled'
|
||||
})
|
||||
}
|
||||
const seed = index === 0 ? params.seed : Math.floor(Math.random() * 2_147_483_647)
|
||||
library.prompt = prompt
|
||||
library.seed = seed
|
||||
library.chainIndex = index
|
||||
library.chainStep = index + 1
|
||||
library.chainLabel = chainTotal > 1 ? `Pass ${index + 1}` : undefined
|
||||
const reference = index === 0 ? params.reference : null
|
||||
const dual = Boolean(reference)
|
||||
const passName = stillChainName(library.name || '', index)
|
||||
|
||||
emitJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
|
||||
const buffer = await downloadEditedImage(output)
|
||||
const size = imageDimensions(buffer)
|
||||
const still = await saveStill({
|
||||
ownerKey: job.library!.ownerKey,
|
||||
folderId: job.library!.folderId,
|
||||
filename: job.library?.name ? `${job.library.name}.png` : output.filename,
|
||||
data: buffer,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput: job.library?.hideInput
|
||||
})
|
||||
job.stillId = still?.id
|
||||
await purgeComfyArtifacts({
|
||||
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
|
||||
imageName: uploaded.name,
|
||||
imageSubfolder: uploaded.subfolder,
|
||||
extraImageNames: uploadedRef?.name ? [uploadedRef.name] : [],
|
||||
promptId: job.promptId
|
||||
})
|
||||
job.status = 'complete'
|
||||
emitJob(job, {
|
||||
type: 'complete',
|
||||
message: 'Edit finished on Beast',
|
||||
progress: 100,
|
||||
stillId: still?.id,
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder,
|
||||
mediaType: 'image',
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: job.library?.folderLocked
|
||||
})
|
||||
emitChainJob(job, {
|
||||
type: 'status',
|
||||
message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...',
|
||||
progress: 8
|
||||
})
|
||||
const uploaded = await uploadImage(current, job.id)
|
||||
const uploadedRef = reference
|
||||
? await uploadImage({
|
||||
...reference,
|
||||
filename: `ref_${reference.filename || 'image2.png'}`
|
||||
}, job.id)
|
||||
: null
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
|
||||
emitChainJob(job, {
|
||||
type: 'status',
|
||||
message: dual
|
||||
? 'Queueing Flux.2 Klein two-image edit on Beast...'
|
||||
: 'Queueing Flux.2 Klein edit on Beast...',
|
||||
progress: 12
|
||||
})
|
||||
const graph = buildEditWorkflow({
|
||||
prompt,
|
||||
negative: params.negative,
|
||||
imageName: uploaded.name,
|
||||
referenceImageName: uploadedRef?.name,
|
||||
steps: params.steps,
|
||||
seed,
|
||||
cfg: params.cfg,
|
||||
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}_p${index + 1}`
|
||||
})
|
||||
const queued = await queuePrompt(graph, job.clientId)
|
||||
job.promptId = queued.prompt_id
|
||||
job.status = 'running'
|
||||
emitChainJob(job, {
|
||||
type: 'status',
|
||||
message: dual ? 'Applying image 2 onto image 1...' : 'Editing still on Beast...',
|
||||
progress: 18,
|
||||
maxStep: params.steps
|
||||
})
|
||||
|
||||
const output = await waitForImageEdit({
|
||||
promptId: queued.prompt_id,
|
||||
clientId: job.clientId,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
onProgress: (event) => {
|
||||
emitChainJob(job, {
|
||||
type: 'status',
|
||||
message: event.message,
|
||||
progress: event.progress,
|
||||
step: event.step,
|
||||
maxStep: event.maxStep || params.steps,
|
||||
node: event.node
|
||||
})
|
||||
},
|
||||
isCancelled: () => job.status === 'cancelled'
|
||||
})
|
||||
|
||||
emitChainJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
|
||||
const buffer = await downloadEditedImage(output)
|
||||
const size = imageDimensions(buffer)
|
||||
const still = await saveStill({
|
||||
ownerKey: library.ownerKey,
|
||||
folderId: library.folderId,
|
||||
filename: passName ? `${passName}.png` : output.filename,
|
||||
data: buffer,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput: job.hideThumbnail === true || library.hideInput === true,
|
||||
role: 'output',
|
||||
name: passName || undefined,
|
||||
prompt,
|
||||
familyId: library.familyId,
|
||||
parentStillId,
|
||||
chainIndex: index
|
||||
})
|
||||
job.stillId = still?.id
|
||||
parentStillId = still?.id
|
||||
await purgeComfyArtifacts({
|
||||
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
|
||||
imageName: uploaded.name,
|
||||
imageSubfolder: uploaded.subfolder,
|
||||
extraImageNames: uploadedRef?.name ? [uploadedRef.name] : [],
|
||||
promptId: job.promptId
|
||||
})
|
||||
|
||||
if (!last) {
|
||||
emitChainJob(job, {
|
||||
type: 'checkpoint',
|
||||
message: `Pass ${index + 1} saved`,
|
||||
progress: 100,
|
||||
stillId: still?.id,
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: library.folderLocked
|
||||
})
|
||||
current = {
|
||||
filename: still?.filename || `pass_${index + 1}.png`,
|
||||
data: buffer,
|
||||
type: 'image/png'
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
job.status = 'complete'
|
||||
emitChainJob(job, {
|
||||
type: 'complete',
|
||||
message: chainTotal > 1 ? 'Edit chain finished on Beast' : 'Edit finished on Beast',
|
||||
progress: 100,
|
||||
stillId: still?.id,
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder,
|
||||
mediaType: 'image',
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: library.folderLocked
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+10
-12
@@ -123,22 +123,20 @@ export default defineEventHandler(async (event) => {
|
||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||
assertFolderExists(event, folderId)
|
||||
const still = isPipelineFrameFilename(image.filename)
|
||||
? null
|
||||
: await saveStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width,
|
||||
height,
|
||||
hideInput
|
||||
})
|
||||
const still = await rememberInputStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width,
|
||||
height,
|
||||
hideInput
|
||||
})
|
||||
const referenceStillIds: Array<string | null> = [null, null, null, null]
|
||||
if (useIdentityRefs) {
|
||||
for (const [index, ref] of referenceImages.entries()) {
|
||||
if (!ref?.data?.length || isPipelineFrameFilename(ref.filename)) continue
|
||||
const saved = await saveStill({
|
||||
const saved = await rememberInputStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: ref.filename || `identity-ref-${index + 2}.png`,
|
||||
|
||||
@@ -31,7 +31,8 @@ export default defineEventHandler(async (event) => {
|
||||
data: image.data,
|
||||
width: Number(fields.width || 0),
|
||||
height: Number(fields.height || 0),
|
||||
hideInput: fields.hideInput === 'true'
|
||||
hideInput: fields.hideInput === 'true',
|
||||
role: 'input'
|
||||
})
|
||||
return { still: still || undefined, ...publicLibrary(event) }
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ export default defineEventHandler(async (event) => {
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const still = getStill(owner, id)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
await deleteStill(owner, id)
|
||||
const family = ['1', 'true'].includes(String(getQuery(event).family || '').toLowerCase())
|
||||
if (family) await deleteStillFamily(owner, id)
|
||||
else await deleteStill(owner, id)
|
||||
return publicLibrary(event)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const body = await readBody<{ folderId?: string; name?: string; family?: boolean }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean }))
|
||||
const folderId = String(body?.folderId || '')
|
||||
const hasName = typeof body?.name === 'string'
|
||||
const family = body?.family === true
|
||||
if (!folderId && !hasName) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
|
||||
}
|
||||
const still = getStill(owner, id)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
if (folderId) {
|
||||
assertFolderExists(event, folderId)
|
||||
if (family) await moveStillFamily(owner, id, folderId)
|
||||
else await moveStill(owner, id, folderId)
|
||||
}
|
||||
if (hasName) {
|
||||
if (family) await renameStillFamily(owner, id, body.name || '')
|
||||
else await renameStill(owner, id, body.name || '')
|
||||
}
|
||||
return publicLibrary(event)
|
||||
})
|
||||
@@ -82,8 +82,10 @@ export interface Job {
|
||||
extendSourceClipId?: string
|
||||
familyId?: string
|
||||
parentClipId?: string
|
||||
parentStillId?: string
|
||||
workflow?: 'v1' | 'v2'
|
||||
chainContinuing?: boolean
|
||||
passes?: { prompt: string }[]
|
||||
}
|
||||
error?: string
|
||||
socketReady?: boolean
|
||||
|
||||
+181
-16
@@ -44,6 +44,8 @@ export interface LibraryClip {
|
||||
hideInput?: boolean
|
||||
}
|
||||
|
||||
export type StillRole = 'input' | 'output'
|
||||
|
||||
export interface LibraryStill {
|
||||
id: string
|
||||
folderId: string
|
||||
@@ -53,6 +55,12 @@ export interface LibraryStill {
|
||||
hash: string
|
||||
createdAt: number
|
||||
hideInput: boolean
|
||||
role: StillRole
|
||||
name?: string
|
||||
prompt?: string
|
||||
familyId?: string
|
||||
parentStillId?: string
|
||||
chainIndex?: number
|
||||
}
|
||||
|
||||
export interface PublicFolder {
|
||||
@@ -63,6 +71,7 @@ export interface PublicFolder {
|
||||
protected: boolean
|
||||
unlocked: boolean
|
||||
clipCount: number
|
||||
imageCount: number
|
||||
}
|
||||
|
||||
export interface QueuedExtension {
|
||||
@@ -153,6 +162,18 @@ function normalizeFolder(folder: LibraryFolder): LibraryFolder {
|
||||
}
|
||||
}
|
||||
|
||||
function generatedOutputFilename(name: string) {
|
||||
const filename = String(name || '').trim()
|
||||
return /aigen[_-]?edit/i.test(filename)
|
||||
|| /^ComfyUI[_-]/i.test(filename)
|
||||
|| /_\d{5}_(?:\.[a-z0-9]+)?$/i.test(filename)
|
||||
}
|
||||
|
||||
function stillRole(still: Pick<LibraryStill, 'role' | 'filename'>): StillRole {
|
||||
if (still.role === 'output' || still.role === 'input') return still.role
|
||||
return generatedOutputFilename(still.filename) ? 'output' : 'input'
|
||||
}
|
||||
|
||||
function normalizeStill(still: LibraryStill): LibraryStill {
|
||||
return {
|
||||
id: still.id,
|
||||
@@ -162,7 +183,13 @@ function normalizeStill(still: LibraryStill): LibraryStill {
|
||||
height: still.height || 0,
|
||||
hash: still.hash || '',
|
||||
createdAt: still.createdAt || Date.now(),
|
||||
hideInput: still.hideInput === true
|
||||
hideInput: still.hideInput === true,
|
||||
role: stillRole(still),
|
||||
name: still.name,
|
||||
prompt: still.prompt,
|
||||
familyId: still.familyId,
|
||||
parentStillId: still.parentStillId,
|
||||
chainIndex: still.chainIndex
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +320,24 @@ export function clipsInSameFamily(catalog: Catalog, clip: LibraryClip) {
|
||||
return folderClips.filter(item => ids.has(item.id))
|
||||
}
|
||||
|
||||
export function stillsInSameFamily(catalog: Catalog, still: LibraryStill) {
|
||||
const folderStills = catalog.stills.filter(item => item.folderId === still.folderId && stillRole(item) === 'output')
|
||||
const ids = new Set<string>([still.id])
|
||||
if (still.familyId) {
|
||||
for (const item of folderStills) {
|
||||
if (item.familyId === still.familyId) ids.add(item.id)
|
||||
}
|
||||
}
|
||||
return folderStills.filter(item => ids.has(item.id))
|
||||
}
|
||||
|
||||
export function stillChainName(base: string, index: number) {
|
||||
const trimmed = (base || '').trim()
|
||||
if (!trimmed || index <= 0) return trimmed.slice(0, 80)
|
||||
const suffix = ` p${index + 1}`
|
||||
return `${trimmed.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
||||
}
|
||||
|
||||
export function safeDownloadName(name: string) {
|
||||
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
|
||||
return cleaned.slice(0, 80) || 'aigen'
|
||||
@@ -327,7 +372,7 @@ export function folderUnlocked(event: H3Event, folder: LibraryFolder) {
|
||||
return folderUnlocks(event)[folder.id] === passwordVersion(folder.passwordHash)
|
||||
}
|
||||
|
||||
export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0): PublicFolder {
|
||||
export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0, imageCount = 0): PublicFolder {
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
@@ -335,7 +380,8 @@ export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount =
|
||||
thumbnailDefault: folder.thumbnailDefault,
|
||||
protected: Boolean(folder.passwordHash),
|
||||
unlocked: folderUnlocked(event, folder),
|
||||
clipCount
|
||||
clipCount,
|
||||
imageCount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +391,8 @@ export function publicLibrary(event: H3Event) {
|
||||
const folders = catalog.folders.map(folder => publicFolder(
|
||||
event,
|
||||
folder,
|
||||
catalog.clips.filter(clip => clip.folderId === folder.id).length
|
||||
catalog.clips.filter(clip => clip.folderId === folder.id).length,
|
||||
catalog.stills.filter(still => still.folderId === folder.id && !isPipelineStill(catalog, still) && stillRole(still) === 'output').length
|
||||
))
|
||||
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
|
||||
return {
|
||||
@@ -356,7 +403,9 @@ export function publicLibrary(event: H3Event) {
|
||||
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id)),
|
||||
hasVideo: existsSync(clipVideoPath(owner, clip.id))
|
||||
})),
|
||||
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId) && !isStrayExtensionStill(catalog, still)),
|
||||
stills: catalog.stills
|
||||
.filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still))
|
||||
.map(still => ({ ...still, role: stillRole(still) })),
|
||||
drafts: catalog.drafts
|
||||
}
|
||||
}
|
||||
@@ -712,11 +761,11 @@ function clipIdFromStillFilename(name: string) {
|
||||
return match ? match[1].toLowerCase() : ''
|
||||
}
|
||||
|
||||
function isStrayExtensionStill(catalog: Catalog, still: LibraryStill) {
|
||||
function isPipelineStill(catalog: Catalog, still: LibraryStill) {
|
||||
if (lastFrameStillName(still.filename)) return true
|
||||
const clipId = clipIdFromStillFilename(still.filename)
|
||||
if (!clipId) return false
|
||||
return catalog.clips.some(clip => clip.id.toLowerCase() === clipId && isExtensionClip(clip))
|
||||
return catalog.clips.some(clip => clip.id.toLowerCase() === clipId)
|
||||
}
|
||||
|
||||
function originalInputHashes(owner: string, catalog: Catalog) {
|
||||
@@ -753,7 +802,6 @@ function allLastFrameHashes(owner: string, catalog: Catalog) {
|
||||
function purgeStrayExtensionStills(owner: string, catalog: Catalog) {
|
||||
const protectedIds = new Set((catalog.drafts || []).map(draft => draft.stillId).filter(Boolean) as string[])
|
||||
const originalHashes = originalInputHashes(owner, catalog)
|
||||
const extIds = new Set(catalog.clips.filter(isExtensionClip).map(clip => clip.id.toLowerCase()))
|
||||
const extHashes = extensionFrameHashes(owner, catalog)
|
||||
const lastHashes = allLastFrameHashes(owner, catalog)
|
||||
const keep: LibraryStill[] = []
|
||||
@@ -761,7 +809,7 @@ function purgeStrayExtensionStills(owner: string, catalog: Catalog) {
|
||||
for (const still of catalog.stills) {
|
||||
const clipId = clipIdFromStillFilename(still.filename)
|
||||
const namedLastFrame = lastFrameStillName(still.filename)
|
||||
const namedExtClip = Boolean(clipId && extIds.has(clipId))
|
||||
const namedExtClip = Boolean(clipId && catalog.clips.some(clip => clip.id.toLowerCase() === clipId))
|
||||
const matchesPipelineFrame = Boolean(still.hash && (lastHashes.has(still.hash) || extHashes.has(still.hash)))
|
||||
const isOriginal = Boolean(still.hash && originalHashes.has(still.hash))
|
||||
const dropThis = !protectedIds.has(still.id)
|
||||
@@ -811,25 +859,47 @@ export async function saveStill(params: {
|
||||
width?: number
|
||||
height?: number
|
||||
hideInput?: boolean
|
||||
role?: StillRole
|
||||
name?: string
|
||||
prompt?: string
|
||||
familyId?: string
|
||||
parentStillId?: string
|
||||
chainIndex?: number
|
||||
}): Promise<LibraryStill | null> {
|
||||
if (lastFrameStillName(params.filename)) return null
|
||||
const role = params.role === 'output' ? 'output' : 'input'
|
||||
const catalog = readCatalog(params.ownerKey)
|
||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
||||
const hash = bytesHash(params.data)
|
||||
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash)
|
||||
const sameChain = (item: LibraryStill) => !params.familyId
|
||||
|| (item.familyId === params.familyId && (item.chainIndex || 0) === (params.chainIndex || 0))
|
||||
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash && stillRole(item) === role && sameChain(item))
|
||||
if (existing) {
|
||||
if (lastFrameStillName(existing.filename)) return null
|
||||
if (typeof params.hideInput === 'boolean' && existing.hideInput !== params.hideInput) {
|
||||
if (lastFrameStillName(existing.filename) || isPipelineStill(catalog, existing)) return null
|
||||
const hideInput = typeof params.hideInput === 'boolean' ? params.hideInput === true : existing.hideInput
|
||||
const name = typeof params.name === 'string' ? params.name.trim().slice(0, 80) : existing.name
|
||||
const prompt = typeof params.prompt === 'string' ? params.prompt : existing.prompt
|
||||
if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt) {
|
||||
await mutate(params.ownerKey, (next) => {
|
||||
const item = next.stills.find(still => still.id === existing.id)
|
||||
if (item) item.hideInput = params.hideInput === true
|
||||
if (!item) return
|
||||
item.hideInput = hideInput
|
||||
item.role = role
|
||||
if (name) item.name = name
|
||||
if (prompt) item.prompt = prompt
|
||||
if (params.familyId) item.familyId = params.familyId
|
||||
if (params.parentStillId) item.parentStillId = params.parentStillId
|
||||
if (typeof params.chainIndex === 'number') item.chainIndex = params.chainIndex
|
||||
})
|
||||
existing.hideInput = params.hideInput === true
|
||||
existing.hideInput = hideInput
|
||||
existing.role = role
|
||||
if (name) existing.name = name
|
||||
if (prompt) existing.prompt = prompt
|
||||
}
|
||||
return existing
|
||||
}
|
||||
if (allLastFrameHashes(params.ownerKey, catalog).has(hash) || extensionFrameHashes(params.ownerKey, catalog).has(hash)) {
|
||||
if (role === 'input' && (allLastFrameHashes(params.ownerKey, catalog).has(hash) || extensionFrameHashes(params.ownerKey, catalog).has(hash))) {
|
||||
return null
|
||||
}
|
||||
const still: LibraryStill = {
|
||||
@@ -840,7 +910,13 @@ export async function saveStill(params: {
|
||||
height: params.height || 0,
|
||||
hash,
|
||||
createdAt: Date.now(),
|
||||
hideInput: params.hideInput === true
|
||||
hideInput: params.hideInput === true,
|
||||
role,
|
||||
name: params.name?.trim().slice(0, 80) || undefined,
|
||||
prompt: params.prompt || undefined,
|
||||
familyId: params.familyId,
|
||||
parentStillId: params.parentStillId,
|
||||
chainIndex: params.chainIndex
|
||||
}
|
||||
mkdirSync(join(ownerRoot(params.ownerKey), 'stills'), { recursive: true })
|
||||
writeFileSync(stillPath(params.ownerKey, still.id), params.data)
|
||||
@@ -850,6 +926,26 @@ export async function saveStill(params: {
|
||||
return still
|
||||
}
|
||||
|
||||
export async function rememberInputStill(params: {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
filename: string
|
||||
data: Buffer
|
||||
width?: number
|
||||
height?: number
|
||||
hideInput?: boolean
|
||||
}): Promise<LibraryStill | null> {
|
||||
if (lastFrameStillName(params.filename)) return null
|
||||
const catalog = readCatalog(params.ownerKey)
|
||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
||||
const hash = bytesHash(params.data)
|
||||
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash && !isPipelineStill(catalog, item))
|
||||
if (existing) return existing
|
||||
if (/^start\.(jpe?g|png|webp)$/i.test(String(params.filename || '').trim())) return null
|
||||
return saveStill({ ...params, role: 'input' })
|
||||
}
|
||||
|
||||
export async function saveRetryDraft(params: {
|
||||
ownerKey: string
|
||||
id?: string
|
||||
@@ -938,6 +1034,75 @@ export function deleteStill(owner: string, id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function moveStill(owner: string, id: string, folderId: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const still = catalog.stills.find(item => item.id === id)
|
||||
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
|
||||
const folder = catalog.folders.find(item => item.id === folderId)
|
||||
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
||||
still.folderId = folder.id
|
||||
return still
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteStillFamily(owner: string, id: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const still = catalog.stills.find(item => item.id === id)
|
||||
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
|
||||
const family = stillsInSameFamily(catalog, still)
|
||||
const ids = new Set(family.map(item => item.id))
|
||||
catalog.stills = catalog.stills.filter(item => !ids.has(item.id))
|
||||
for (const item of family) {
|
||||
rmSync(stillPath(owner, item.id), { force: true })
|
||||
}
|
||||
return family
|
||||
})
|
||||
}
|
||||
|
||||
export function moveStillFamily(owner: string, id: string, folderId: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const still = catalog.stills.find(item => item.id === id)
|
||||
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
|
||||
const folder = catalog.folders.find(item => item.id === folderId)
|
||||
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
||||
const family = stillsInSameFamily(catalog, still)
|
||||
const familyId = family.find(item => item.familyId)?.familyId || still.familyId || crypto.randomUUID()
|
||||
for (const item of family) {
|
||||
item.folderId = folder.id
|
||||
item.familyId = familyId
|
||||
}
|
||||
return family
|
||||
})
|
||||
}
|
||||
|
||||
export function renameStill(owner: string, id: string, name: string) {
|
||||
const trimmed = name.trim().slice(0, 80)
|
||||
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
||||
return mutate(owner, (catalog) => {
|
||||
const still = catalog.stills.find(item => item.id === id)
|
||||
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
|
||||
still.name = trimmed
|
||||
return still
|
||||
})
|
||||
}
|
||||
|
||||
export function renameStillFamily(owner: string, id: string, name: string) {
|
||||
const trimmed = name.trim().slice(0, 80)
|
||||
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
||||
return mutate(owner, (catalog) => {
|
||||
const still = catalog.stills.find(item => item.id === id)
|
||||
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
|
||||
const family = [...stillsInSameFamily(catalog, still)].sort((a, b) => (a.chainIndex || 0) - (b.chainIndex || 0) || a.createdAt - b.createdAt)
|
||||
const base = familyBaseName(trimmed) || trimmed
|
||||
const familyId = family.find(item => item.familyId)?.familyId || still.familyId || crypto.randomUUID()
|
||||
family.forEach((item, index) => {
|
||||
item.familyId = familyId
|
||||
item.name = stillChainName(base, index)
|
||||
})
|
||||
return family
|
||||
})
|
||||
}
|
||||
|
||||
export function imageContentType(buf: Buffer) {
|
||||
if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png'
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'
|
||||
|
||||
Reference in New Issue
Block a user