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:
Towsty
2026-08-27 09:58:08 -05:00
co-authored by Cursor
parent f88dfa1146
commit 860aeafb1e
9 changed files with 1001 additions and 177 deletions
+34 -4
View File
@@ -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) {
+545 -34
View File
@@ -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">
<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
if (complete && payload.stillId) {
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 (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
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
+121 -29
View File
@@ -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,9 +82,7 @@ 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({
const still = await rememberInputStill({
ownerKey,
folderId,
filename: image.filename,
@@ -76,8 +91,8 @@ export default defineEventHandler(async (event) => {
height: size?.height || 0,
hideInput
})
if (reference && !isPipelineFrameFilename(reference.filename)) {
await saveStill({
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,22 +200,47 @@ async function runEdit(
job.imageComfyHost = getComfyHost()
await withImageComfyHost(job.imageComfyHost, async () => {
const dual = Boolean(params.reference)
emitJob(job, {
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'
})
})
}
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)
emitChainJob(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
const uploaded = await uploadImage(current, job.id)
const uploadedRef = reference
? await uploadImage({
...params.reference,
filename: `ref_${params.reference.filename || 'image2.png'}`
...reference,
filename: `ref_${reference.filename || 'image2.png'}`
}, job.id)
: null
if (job.status === 'cancelled') throw new Error('Job interrupted.')
emitJob(job, {
emitChainJob(job, {
type: 'status',
message: dual
? 'Queueing Flux.2 Klein two-image edit on Beast...'
@@ -182,19 +248,19 @@ async function runEdit(
progress: 12
})
const graph = buildEditWorkflow({
prompt: params.prompt,
prompt,
negative: params.negative,
imageName: uploaded.name,
referenceImageName: uploadedRef?.name,
steps: params.steps,
seed: params.seed,
seed,
cfg: params.cfg,
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}`
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'
emitJob(job, {
emitChainJob(job, {
type: 'status',
message: dual ? 'Applying image 2 onto image 1...' : 'Editing still on Beast...',
progress: 18,
@@ -206,7 +272,7 @@ async function runEdit(
clientId: job.clientId,
timeoutMs: 10 * 60 * 1000,
onProgress: (event) => {
emitJob(job, {
emitChainJob(job, {
type: 'status',
message: event.message,
progress: event.progress,
@@ -218,19 +284,26 @@ async function runEdit(
isCancelled: () => job.status === 'cancelled'
})
emitJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
emitChainJob(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,
ownerKey: library.ownerKey,
folderId: library.folderId,
filename: passName ? `${passName}.png` : output.filename,
data: buffer,
width: size?.width || 0,
height: size?.height || 0,
hideInput: job.library?.hideInput
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,
@@ -238,17 +311,36 @@ async function runEdit(
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'
emitJob(job, {
emitChainJob(job, {
type: 'complete',
message: 'Edit finished on Beast',
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: job.library?.folderLocked
folderLocked: library.folderLocked
})
}
})
}
+2 -4
View File
@@ -123,9 +123,7 @@ 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({
const still = await rememberInputStill({
ownerKey,
folderId,
filename: image.filename,
@@ -138,7 +136,7 @@ export default defineEventHandler(async (event) => {
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`,
+2 -1
View File
@@ -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 -1
View File
@@ -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)
})
+23
View File
@@ -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)
})
+2
View File
@@ -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
View File
@@ -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'