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 class="flex items-start justify-between gap-3">
<div> <div>
<h2 class="font-display text-2xl font-bold">{{ heading || 'Choose an image' }}</h2> <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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
@@ -32,6 +32,25 @@
</div> </div>
<div class="mt-5 flex flex-wrap gap-2"> <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 <button
v-for="folder in folders" v-for="folder in folders"
:key="folder.id" :key="folder.id"
@@ -52,6 +71,7 @@
</form> </form>
<div <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="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 }" :class="{ 'border-amber-300/70 bg-amber-400/5': dragging }"
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@@ -65,7 +85,7 @@
</div> </div>
<template v-if="current && (!current.protected || current.unlocked)"> <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"> <div v-if="visibleStills.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<article <article
v-for="still in visibleStills" v-for="still in visibleStills"
@@ -112,7 +132,7 @@
</div> </div>
</div> </div>
<div class="flex items-center justify-between gap-2 px-3 py-2"> <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> <button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click="removeStill(still)">Delete</button>
</div> </div>
</article> </article>
@@ -140,6 +160,8 @@ export interface PickerStill {
height: number height: number
createdAt: number createdAt: number
hideInput?: boolean hideInput?: boolean
role?: 'input' | 'output'
name?: string
} }
const props = defineProps<{ const props = defineProps<{
@@ -161,6 +183,7 @@ const fileInput = ref<HTMLInputElement | null>(null)
const dragging = ref(false) const dragging = ref(false)
const password = ref('') const password = ref('')
const activeFolderId = ref(props.folderId) const activeFolderId = ref(props.folderId)
const tab = ref<'input' | 'output'>('input')
const revealAll = ref(false) const revealAll = ref(false)
const revealedIds = ref<string[]>([]) 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 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))) const hasHiddenItems = computed(() => visibleStills.value.some(still => stillHidden(still)))
function stillHidden(still: PickerStill) { function stillHidden(still: PickerStill) {
+548 -37
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" 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" :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"> <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> <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> <p class="mt-2 text-[10px] uppercase tracking-wide text-zinc-500">Identity prefix</p>
@@ -504,7 +537,7 @@
:disabled="studioMode === 'edit' ? editBlocked : videoGenerateDisabled" :disabled="studioMode === 'edit' ? editBlocked : videoGenerateDisabled"
@click="studioMode === 'edit' ? editImage() : generate()" @click="studioMode === 'edit' ? editImage() : generate()"
> >
{{ studioMode === 'edit' ? (editBusy ? 'Editing…' : 'Edit image') : generateLabel }} {{ studioMode === 'edit' ? (editBusy ? 'Editing…' : editLabel) : generateLabel }}
</button> </button>
<button <button
v-if="studioMode === 'edit' ? editBusy : videoBusy" v-if="studioMode === 'edit' ? editBusy : videoBusy"
@@ -542,6 +575,7 @@
<h2 class="font-display text-2xl font-bold">Output</h2> <h2 class="font-display text-2xl font-bold">Output</h2>
<p v-if="studioMode === 'edit'" class="text-xs text-zinc-500"> <p v-if="studioMode === 'edit'" class="text-xs text-zinc-500">
{{ editElapsedLabel }} {{ editElapsedLabel }}
<template v-if="editChainTotal > 1"> · chain {{ editChainStep }}/{{ editChainTotal }}</template>
· step {{ editStep }}/{{ editMaxStep }} · step {{ editStep }}/{{ editMaxStep }}
</p> </p>
<p v-else class="text-xs text-zinc-500"> <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="editStatusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ editStatusMessage }}</span>
<span class="text-amber-200">{{ editProgress }}%</span> <span class="text-amber-200">{{ editProgress }}%</span>
</div> </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-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> </div>
<div <div
@@ -743,7 +793,10 @@
<div class="glass rounded-3xl p-5 sm:p-6"> <div class="glass rounded-3xl p-5 sm:p-6">
<div class="flex items-center justify-between gap-3"> <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 <button
v-if="browseFolder?.protected && browseFolder.unlocked" v-if="browseFolder?.protected && browseFolder.unlocked"
type="button" type="button"
@@ -755,7 +808,7 @@
</div> </div>
<form v-if="browseFolder?.protected && !browseFolder.unlocked" class="mt-4 space-y-3" @submit.prevent="unlockFolder(browseFolderId)"> <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"> <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> <button type="submit" class="w-full rounded-xl bg-amber-400 px-4 py-2 font-semibold text-zinc-950">Unlock</button>
</form> </form>
@@ -771,25 +824,39 @@
@click="browseFolderId = folder.id" @click="browseFolderId = folder.id"
> >
{{ folder.name }} {{ 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> <span v-if="folder.protected">{{ folder.unlocked ? '' : ' · lock' }}</span>
</button> </button>
</div> </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"> <ul class="mt-4 space-y-3">
<li <li
v-for="group in libraryGroups" v-for="entry in libraryEntries"
:key="group.key" :key="entry.key"
class="rounded-2xl border border-white/5 bg-zinc-950/40 p-3" class="rounded-2xl border border-white/5 bg-zinc-950/40 p-3"
> >
<template v-if="entry.kind === 'clip'">
<div class="flex gap-3"> <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)"> <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="group.head.hideThumbnail" class="sensitive-glow block h-full w-full" /> <span v-if="entry.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"> <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 <video
v-else 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" class="h-full w-full object-cover"
muted muted
playsinline playsinline
@@ -799,23 +866,23 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<input <input
v-if="renamingId === group.head.id && renamingFamily" v-if="renamingId === entry.group.head.id && renamingFamily"
ref="renameInput" ref="renameInput"
v-model="renameDraft" 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" 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" maxlength="80"
@keydown.enter.prevent="saveRename(group.head, true)" @keydown.enter.prevent="saveRename(entry.group.head, true)"
@keydown.escape.prevent="cancelRename" @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"> <div class="group relative shrink-0">
<button <button
type="button" type="button"
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200" 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" 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"> <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" /> <circle cx="12" cy="12" r="10" />
@@ -823,41 +890,41 @@
</svg> </svg>
</button> </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"> <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> </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"> <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)"> <button v-if="entry.group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.group.key)">
{{ expandedFamilies[group.key] ? 'Collapse' : `Expand ${group.parts.length} parts` }} {{ expandedFamilies[entry.group.key] ? 'Collapse' : `Expand ${entry.group.parts.length} parts` }}
</button> </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="openClip(entry.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="openExtend(entry.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="startRename(entry.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="rerun(entry.group.head, true)">Rerun</button>
<select <select
v-if="folders.length > 1" 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" 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="''" :value="''"
@change="onMoveClip(group.head, $event, true)" @change="onMoveClip(entry.group.head, $event, true)"
> >
<option disabled value="">Move to…</option> <option disabled value="">Move to…</option>
<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" :key="folder.id"
:value="folder.id" :value="folder.id"
> >
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }} {{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
</option> </option>
</select> </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> </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 <li
v-for="item in chronologicalParts(group.parts)" v-for="item in chronologicalParts(entry.group.parts)"
:key="item.id" :key="item.id"
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2" class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
> >
@@ -912,6 +979,130 @@
</div> </div>
</li> </li>
</ul> </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> </li>
</ul> </ul>
</template> </template>
@@ -967,6 +1158,7 @@ interface LibraryFolder {
protected: boolean protected: boolean
unlocked: boolean unlocked: boolean
clipCount?: number clipCount?: number
imageCount?: number
} }
interface LibraryStill { interface LibraryStill {
@@ -977,6 +1169,12 @@ interface LibraryStill {
height: number height: number
createdAt: number createdAt: number
hideInput?: boolean hideInput?: boolean
role?: 'input' | 'output'
name?: string
prompt?: string
familyId?: string
parentStillId?: string
chainIndex?: number
} }
interface LibraryClip { interface LibraryClip {
@@ -1040,6 +1238,11 @@ interface QueuedExtension {
duration: number duration: number
} }
interface QueuedEditPass {
id: string
prompt: string
}
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = { const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
'16:9': { width: 1344, height: 768 }, '16:9': { width: 1344, height: 768 },
'9:16': { width: 768, height: 1344 }, '9:16': { width: 768, height: 1344 },
@@ -1061,6 +1264,7 @@ const dragging = ref(false)
const prompt = ref('') const prompt = ref('')
const shotScriptMode = ref(false) const shotScriptMode = ref(false)
const extensionQueue = ref<QueuedExtension[]>([]) const extensionQueue = ref<QueuedExtension[]>([])
const editPassQueue = ref<QueuedEditPass[]>([])
const clipName = ref('') const clipName = ref('')
const videoDownloadName = ref('aigen') const videoDownloadName = ref('aigen')
const editDownloadName = ref('aigen-edit') const editDownloadName = ref('aigen-edit')
@@ -1128,6 +1332,12 @@ const editCfg = ref(1)
const editNegative = ref('') const editNegative = ref('')
const editRefFile = ref<File | null>(null) const editRefFile = ref<File | null>(null)
const editRefPreview = ref('') 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 videoJobId = ref('')
const editJobId = ref('') const editJobId = ref('')
const currentClipId = ref('') const currentClipId = ref('')
@@ -1165,6 +1375,7 @@ const unlockPassword = ref('')
const settingsOpen = ref(false) const settingsOpen = ref(false)
const pickerOpen = ref(false) const pickerOpen = ref(false)
const pickerSlot = ref<'main' | 'editRef' | number>('main') const pickerSlot = ref<'main' | 'editRef' | number>('main')
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
const videoWorkflow = ref('v1') const videoWorkflow = ref('v1')
const useIdentityRefs = ref(false) const useIdentityRefs = ref(false)
const identityRefs = ref<(File | null)[]>([null, null, null, null]) const identityRefs = ref<(File | null)[]>([null, null, null, null])
@@ -1305,6 +1516,7 @@ const videoGenerateDisabled = computed(() => {
const editBlocked = computed(() => { const editBlocked = computed(() => {
if (editBusy.value) return true if (editBusy.value) return true
if (!file.value || !prompt.value.trim() || !folderId.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 if (!comfyOk.value && !imageComfyOk.value) return true
return false return false
}) })
@@ -1319,6 +1531,11 @@ const generateLabel = computed(() => {
if (!n) return 'Generate Video' if (!n) return 'Generate Video'
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions` 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 parsedShots = computed(() => parseShotScript(prompt.value))
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value) const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value)
const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : [])) 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 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 libraryGroups = computed(() => {
const groups = new Map<string, LibraryClip[]>() const groups = new Map<string, LibraryClip[]>()
for (const clip of visibleClips.value) { for (const clip of visibleClips.value) {
@@ -1379,13 +1612,50 @@ const libraryGroups = computed(() => {
}) })
.sort((a, b) => b.head.createdAt - a.head.createdAt) .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 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) { function clipsIn(id: string) {
return clips.value.filter(clip => clip.folderId === id) 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) { function familyBaseName(name: string) {
return String(name || '').trim().replace(/\s+p\d+$/i, '') return String(name || '').trim().replace(/\s+p\d+$/i, '')
} }
@@ -1397,6 +1667,25 @@ function clipFamilyKey(clip: LibraryClip) {
return `solo:${clip.id}` 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) { function sortClipParts(a: LibraryClip, b: LibraryClip) {
const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0) const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0)
if (durationDelta) return durationDelta if (durationDelta) return durationDelta
@@ -1440,6 +1729,31 @@ function clipMetaLine(item: LibraryClip) {
return duration ? `${res} · ${duration} · ${steps} · ${seed}` : `${res} · ${steps} · ${seed}` 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) { function toggleFamily(key: string) {
expandedFamilies.value = { ...expandedFamilies.value, [key]: !expandedFamilies.value[key] } expandedFamilies.value = { ...expandedFamilies.value, [key]: !expandedFamilies.value[key] }
} }
@@ -1464,6 +1778,26 @@ function chainPartDot(index: number) {
return 'bg-zinc-600' 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() { function applyThumbnailDefault() {
if (!showPrivacyToggles.value) { if (!showPrivacyToggles.value) {
hideThumbnail.value = false hideThumbnail.value = false
@@ -2177,6 +2511,7 @@ async function generateExtension() {
} }
function openClip(item: LibraryClip) { function openClip(item: LibraryClip) {
studioMode.value = 'video'
closeExtend() closeExtend()
watchingLibrary.value = true watchingLibrary.value = true
currentClipId.value = item.id 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) { async function removeClip(item: LibraryClip, family = false) {
const watched = family ? familyPartsFor(item) : [item] const watched = family ? familyPartsFor(item) : [item]
try { try {
@@ -2305,12 +2751,28 @@ function removeQueuedExtension(id: string) {
extensionQueue.value = extensionQueue.value.filter(item => item.id !== id) 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() { async function editImage() {
if (!file.value || !prompt.value.trim()) return 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) { if (!folderId.value) {
toast('Choose a library folder before editing.') toast('Choose a library folder before editing.')
return return
} }
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
editStatusBusy.value = false editStatusBusy.value = false
editBusy.value = true editBusy.value = true
stopListen('edit') stopListen('edit')
@@ -2321,6 +2783,15 @@ async function editImage() {
editLockedSave.value = false editLockedSave.value = false
editStatusMessage.value = 'Checking Beast ComfyUI...' editStatusMessage.value = 'Checking Beast ComfyUI...'
editMaxStep.value = editSteps.value 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') startTimer('edit')
try { try {
const body = new FormData() const body = new FormData()
@@ -2334,6 +2805,7 @@ async function editImage() {
body.append('folderId', folderId.value) body.append('folderId', folderId.value)
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value)) body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('name', clipName.value.trim()) 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') 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', { const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/edit', {
method: 'POST', 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.progress === 'number') editProgress.value = payload.progress
if (typeof payload.step === 'number') editStep.value = payload.step if (typeof payload.step === 'number') editStep.value = payload.step
if (typeof payload.maxStep === 'number') editMaxStep.value = payload.maxStep 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 failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
const complete = payload.type === 'complete' || payload.status === 'complete' const complete = payload.type === 'complete' || payload.status === 'complete'
if (editSettledUi) return 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) { 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 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) { if (locked) {
editResultUrl.value = '' editResultUrl.value = ''
editLockedSave.value = true editLockedSave.value = true
@@ -2502,7 +3012,8 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
editResultUrl.value = `/api/library/stills/${payload.stillId}` editResultUrl.value = `/api/library/stills/${payload.stillId}`
editAwaitingReveal.value = Boolean(payload.hideThumbnail || hidden) editAwaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
editLockedSave.value = false editLockedSave.value = false
editStatusMessage.value = 'Edit finished' editStatusMessage.value = total > 1 ? 'Edit chain finished' : 'Edit finished'
editCompletedChainStep.value = total
} }
editBusy.value = false editBusy.value = false
editStatusBusy.value = false editStatusBusy.value = false
+198 -106
View File
@@ -16,6 +16,19 @@ function parseCfg(raw: unknown) {
return Math.min(8, Math.max(0.5, Math.round(value * 10) / 10)) 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) => { export default defineEventHandler(async (event) => {
const form = await readMultipartFormData(event).catch(() => null) const form = await readMultipartFormData(event).catch(() => null)
const fields: Record<string, string> = {} const fields: Record<string, string> = {}
@@ -32,12 +45,16 @@ export default defineEventHandler(async (event) => {
} }
const prompt = (fields.prompt || '').trim() const prompt = (fields.prompt || '').trim()
const extraPasses = parsePasses(fields.passes)
if (!image) { if (!image) {
throw createError({ statusCode: 400, statusMessage: 'An input image is required' }) throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
} }
if (!prompt) { if (!prompt) {
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' }) 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()) { if (!comfyConfigured()) {
throw createError({ throw createError({
statusCode: 503, statusCode: 503,
@@ -65,19 +82,17 @@ export default defineEventHandler(async (event) => {
: Math.floor(Math.random() * 2_147_483_647) : Math.floor(Math.random() * 2_147_483_647)
const size = imageDimensions(image.data) const size = imageDimensions(image.data)
const still = isPipelineFrameFilename(image.filename) const still = await rememberInputStill({
? null ownerKey,
: await saveStill({ folderId,
ownerKey, filename: image.filename,
folderId, data: image.data,
filename: image.filename, width: size?.width || 0,
data: image.data, height: size?.height || 0,
width: size?.width || 0, hideInput
height: size?.height || 0, })
hideInput if (reference) {
}) await rememberInputStill({
if (reference && !isPipelineFrameFilename(reference.filename)) {
await saveStill({
ownerKey, ownerKey,
folderId, folderId,
filename: reference.filename, 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() const job = createJob()
job.kind = 'edit' job.kind = 'edit'
job.maxStep = steps job.maxStep = steps
@@ -105,14 +123,28 @@ export default defineEventHandler(async (event) => {
turbo: true, turbo: true,
seed, seed,
cfg, 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, { void runEdit(job, {
image, image,
reference, reference,
prompt, prompt,
passes,
negative: (fields.negative || '').trim(), negative: (fields.negative || '').trim(),
steps, steps,
seed, seed,
@@ -131,7 +163,8 @@ export default defineEventHandler(async (event) => {
steps, steps,
hideThumbnail: hideInput, hideThumbnail: hideInput,
folderLocked, folderLocked,
dual: Boolean(reference) dual: Boolean(reference),
chainTotal
} }
}) })
@@ -141,14 +174,22 @@ async function runEdit(
image: { filename: string; data: Buffer; type?: string } image: { filename: string; data: Buffer; type?: string }
reference: { filename: string; data: Buffer; type?: string } | null reference: { filename: string; data: Buffer; type?: string } | null
prompt: string prompt: string
passes: { prompt: string }[]
negative: string negative: string
steps: number steps: number
seed: number seed: number
cfg: 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) => { await ensureComfyReady((status) => {
emitJob(job, { emitChainJob(job, {
type: status.state === 'busy' ? 'busy' : 'status', type: status.state === 'busy' ? 'busy' : 'status',
message: status.message, message: status.message,
progress: status.state === 'online' ? Math.max(job.progress, 6) : Math.max(job.progress, 3), progress: status.state === 'online' ? Math.max(job.progress, 6) : Math.max(job.progress, 3),
@@ -159,96 +200,147 @@ async function runEdit(
job.imageComfyHost = getComfyHost() job.imageComfyHost = getComfyHost()
await withImageComfyHost(job.imageComfyHost, async () => { await withImageComfyHost(job.imageComfyHost, async () => {
const dual = Boolean(params.reference) let current = params.image
emitJob(job, { let parentStillId: string | undefined
type: 'status', for (let index = 0; index < prompts.length; index++) {
message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...', if (job.status === 'cancelled') throw new Error('Job interrupted.')
progress: 8 const prompt = prompts[index]
}) const last = index === prompts.length - 1
const uploaded = await uploadImage(params.image, job.id) if (index > 0) {
const uploadedRef = params.reference await ensureComfyReady((status) => {
? await uploadImage({ emitChainJob(job, {
...params.reference, type: status.state === 'busy' ? 'busy' : 'status',
filename: `ref_${params.reference.filename || 'image2.png'}` message: status.message,
}, job.id) progress: status.state === 'online' ? 4 : 2,
: null busy: status.state === 'busy'
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
}) })
}, }
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 }) emitChainJob(job, {
const buffer = await downloadEditedImage(output) type: 'status',
const size = imageDimensions(buffer) message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...',
const still = await saveStill({ progress: 8
ownerKey: job.library!.ownerKey, })
folderId: job.library!.folderId, const uploaded = await uploadImage(current, job.id)
filename: job.library?.name ? `${job.library.name}.png` : output.filename, const uploadedRef = reference
data: buffer, ? await uploadImage({
width: size?.width || 0, ...reference,
height: size?.height || 0, filename: `ref_${reference.filename || 'image2.png'}`
hideInput: job.library?.hideInput }, job.id)
}) : null
job.stillId = still?.id if (job.status === 'cancelled') throw new Error('Job interrupted.')
await purgeComfyArtifacts({
video: { filename: output.filename, subfolder: output.subfolder, type: output.type }, emitChainJob(job, {
imageName: uploaded.name, type: 'status',
imageSubfolder: uploaded.subfolder, message: dual
extraImageNames: uploadedRef?.name ? [uploadedRef.name] : [], ? 'Queueing Flux.2 Klein two-image edit on Beast...'
promptId: job.promptId : 'Queueing Flux.2 Klein edit on Beast...',
}) progress: 12
job.status = 'complete' })
emitJob(job, { const graph = buildEditWorkflow({
type: 'complete', prompt,
message: 'Edit finished on Beast', negative: params.negative,
progress: 100, imageName: uploaded.name,
stillId: still?.id, referenceImageName: uploadedRef?.name,
filename: output.filename, steps: params.steps,
subfolder: output.subfolder, seed,
mediaType: 'image', cfg: params.cfg,
hideThumbnail: job.hideThumbnail, filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}_p${index + 1}`
folderLocked: job.library?.folderLocked })
}) 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
View File
@@ -123,22 +123,20 @@ export default defineEventHandler(async (event) => {
const destFolder = library.folders.find(folder => folder.id === folderId) const destFolder = library.folders.find(folder => folder.id === folderId)
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked) const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
assertFolderExists(event, folderId) assertFolderExists(event, folderId)
const still = isPipelineFrameFilename(image.filename) const still = await rememberInputStill({
? null ownerKey,
: await saveStill({ folderId,
ownerKey, filename: image.filename,
folderId, data: image.data,
filename: image.filename, width,
data: image.data, height,
width, hideInput
height, })
hideInput
})
const referenceStillIds: Array<string | null> = [null, null, null, null] const referenceStillIds: Array<string | null> = [null, null, null, null]
if (useIdentityRefs) { if (useIdentityRefs) {
for (const [index, ref] of referenceImages.entries()) { for (const [index, ref] of referenceImages.entries()) {
if (!ref?.data?.length || isPipelineFrameFilename(ref.filename)) continue if (!ref?.data?.length || isPipelineFrameFilename(ref.filename)) continue
const saved = await saveStill({ const saved = await rememberInputStill({
ownerKey, ownerKey,
folderId, folderId,
filename: ref.filename || `identity-ref-${index + 2}.png`, filename: ref.filename || `identity-ref-${index + 2}.png`,
+2 -1
View File
@@ -31,7 +31,8 @@ export default defineEventHandler(async (event) => {
data: image.data, data: image.data,
width: Number(fields.width || 0), width: Number(fields.width || 0),
height: Number(fields.height || 0), height: Number(fields.height || 0),
hideInput: fields.hideInput === 'true' hideInput: fields.hideInput === 'true',
role: 'input'
}) })
return { still: still || undefined, ...publicLibrary(event) } 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 id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id) const still = getStill(owner, id)
assertFolderAccess(event, still.folderId) 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) 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 extendSourceClipId?: string
familyId?: string familyId?: string
parentClipId?: string parentClipId?: string
parentStillId?: string
workflow?: 'v1' | 'v2' workflow?: 'v1' | 'v2'
chainContinuing?: boolean chainContinuing?: boolean
passes?: { prompt: string }[]
} }
error?: string error?: string
socketReady?: boolean socketReady?: boolean
+181 -16
View File
@@ -44,6 +44,8 @@ export interface LibraryClip {
hideInput?: boolean hideInput?: boolean
} }
export type StillRole = 'input' | 'output'
export interface LibraryStill { export interface LibraryStill {
id: string id: string
folderId: string folderId: string
@@ -53,6 +55,12 @@ export interface LibraryStill {
hash: string hash: string
createdAt: number createdAt: number
hideInput: boolean hideInput: boolean
role: StillRole
name?: string
prompt?: string
familyId?: string
parentStillId?: string
chainIndex?: number
} }
export interface PublicFolder { export interface PublicFolder {
@@ -63,6 +71,7 @@ export interface PublicFolder {
protected: boolean protected: boolean
unlocked: boolean unlocked: boolean
clipCount: number clipCount: number
imageCount: number
} }
export interface QueuedExtension { 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 { function normalizeStill(still: LibraryStill): LibraryStill {
return { return {
id: still.id, id: still.id,
@@ -162,7 +183,13 @@ function normalizeStill(still: LibraryStill): LibraryStill {
height: still.height || 0, height: still.height || 0,
hash: still.hash || '', hash: still.hash || '',
createdAt: still.createdAt || Date.now(), 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)) 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) { export function safeDownloadName(name: string) {
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim() const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
return cleaned.slice(0, 80) || 'aigen' 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) 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 { return {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
@@ -335,7 +380,8 @@ export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount =
thumbnailDefault: folder.thumbnailDefault, thumbnailDefault: folder.thumbnailDefault,
protected: Boolean(folder.passwordHash), protected: Boolean(folder.passwordHash),
unlocked: folderUnlocked(event, folder), unlocked: folderUnlocked(event, folder),
clipCount clipCount,
imageCount
} }
} }
@@ -345,7 +391,8 @@ export function publicLibrary(event: H3Event) {
const folders = catalog.folders.map(folder => publicFolder( const folders = catalog.folders.map(folder => publicFolder(
event, event,
folder, 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)) const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
return { return {
@@ -356,7 +403,9 @@ export function publicLibrary(event: H3Event) {
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id)), hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id)),
hasVideo: existsSync(clipVideoPath(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 drafts: catalog.drafts
} }
} }
@@ -712,11 +761,11 @@ function clipIdFromStillFilename(name: string) {
return match ? match[1].toLowerCase() : '' return match ? match[1].toLowerCase() : ''
} }
function isStrayExtensionStill(catalog: Catalog, still: LibraryStill) { function isPipelineStill(catalog: Catalog, still: LibraryStill) {
if (lastFrameStillName(still.filename)) return true if (lastFrameStillName(still.filename)) return true
const clipId = clipIdFromStillFilename(still.filename) const clipId = clipIdFromStillFilename(still.filename)
if (!clipId) return false 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) { function originalInputHashes(owner: string, catalog: Catalog) {
@@ -753,7 +802,6 @@ function allLastFrameHashes(owner: string, catalog: Catalog) {
function purgeStrayExtensionStills(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 protectedIds = new Set((catalog.drafts || []).map(draft => draft.stillId).filter(Boolean) as string[])
const originalHashes = originalInputHashes(owner, catalog) const originalHashes = originalInputHashes(owner, catalog)
const extIds = new Set(catalog.clips.filter(isExtensionClip).map(clip => clip.id.toLowerCase()))
const extHashes = extensionFrameHashes(owner, catalog) const extHashes = extensionFrameHashes(owner, catalog)
const lastHashes = allLastFrameHashes(owner, catalog) const lastHashes = allLastFrameHashes(owner, catalog)
const keep: LibraryStill[] = [] const keep: LibraryStill[] = []
@@ -761,7 +809,7 @@ function purgeStrayExtensionStills(owner: string, catalog: Catalog) {
for (const still of catalog.stills) { for (const still of catalog.stills) {
const clipId = clipIdFromStillFilename(still.filename) const clipId = clipIdFromStillFilename(still.filename)
const namedLastFrame = lastFrameStillName(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 matchesPipelineFrame = Boolean(still.hash && (lastHashes.has(still.hash) || extHashes.has(still.hash)))
const isOriginal = Boolean(still.hash && originalHashes.has(still.hash)) const isOriginal = Boolean(still.hash && originalHashes.has(still.hash))
const dropThis = !protectedIds.has(still.id) const dropThis = !protectedIds.has(still.id)
@@ -811,25 +859,47 @@ export async function saveStill(params: {
width?: number width?: number
height?: number height?: number
hideInput?: boolean hideInput?: boolean
role?: StillRole
name?: string
prompt?: string
familyId?: string
parentStillId?: string
chainIndex?: number
}): Promise<LibraryStill | null> { }): Promise<LibraryStill | null> {
if (lastFrameStillName(params.filename)) return null if (lastFrameStillName(params.filename)) return null
const role = params.role === 'output' ? 'output' : 'input'
const catalog = readCatalog(params.ownerKey) const catalog = readCatalog(params.ownerKey)
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0] const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' }) if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
const hash = bytesHash(params.data) 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 (existing) {
if (lastFrameStillName(existing.filename)) return null if (lastFrameStillName(existing.filename) || isPipelineStill(catalog, existing)) return null
if (typeof params.hideInput === 'boolean' && existing.hideInput !== params.hideInput) { 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) => { await mutate(params.ownerKey, (next) => {
const item = next.stills.find(still => still.id === existing.id) 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 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 return null
} }
const still: LibraryStill = { const still: LibraryStill = {
@@ -840,7 +910,13 @@ export async function saveStill(params: {
height: params.height || 0, height: params.height || 0,
hash, hash,
createdAt: Date.now(), 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 }) mkdirSync(join(ownerRoot(params.ownerKey), 'stills'), { recursive: true })
writeFileSync(stillPath(params.ownerKey, still.id), params.data) writeFileSync(stillPath(params.ownerKey, still.id), params.data)
@@ -850,6 +926,26 @@ export async function saveStill(params: {
return still 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: { export async function saveRetryDraft(params: {
ownerKey: string ownerKey: string
id?: 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) { export function imageContentType(buf: Buffer) {
if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png' if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png'
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg' if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'