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
+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"
:placeholder="promptPlaceholder"
/>
<div v-if="studioMode === 'edit'" class="mt-3 space-y-3">
<div
v-for="(item, index) in editPassQueue"
:key="item.id"
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
>
<div class="mb-3 flex items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-200">Pass {{ index + 2 }}</p>
<button
type="button"
class="text-xs text-red-300 hover:text-red-200"
:disabled="editBusy"
@click="removeEditPass(item.id)"
>
Remove
</button>
</div>
<textarea
v-model="item.prompt"
rows="3"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Next edit prompt, applied to the previous pass output…"
/>
</div>
<button
type="button"
class="w-full rounded-2xl border border-dashed border-white/15 px-4 py-2.5 text-sm text-zinc-300 transition hover:border-amber-300/40 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="editBusy"
@click="queueEditPass"
>
+ Pass
</button>
</div>
<details v-if="identityPrompting && prompt.trim()" class="mt-2 rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2">
<summary class="cursor-pointer text-xs text-zinc-400">Prompt sent to Reference to Video</summary>
<p class="mt-2 text-[10px] uppercase tracking-wide text-zinc-500">Identity prefix</p>
@@ -504,7 +537,7 @@
:disabled="studioMode === 'edit' ? editBlocked : videoGenerateDisabled"
@click="studioMode === 'edit' ? editImage() : generate()"
>
{{ studioMode === 'edit' ? (editBusy ? 'Editing…' : 'Edit image') : generateLabel }}
{{ studioMode === 'edit' ? (editBusy ? 'Editing…' : editLabel) : generateLabel }}
</button>
<button
v-if="studioMode === 'edit' ? editBusy : videoBusy"
@@ -542,6 +575,7 @@
<h2 class="font-display text-2xl font-bold">Output</h2>
<p v-if="studioMode === 'edit'" class="text-xs text-zinc-500">
{{ editElapsedLabel }}
<template v-if="editChainTotal > 1"> · chain {{ editChainStep }}/{{ editChainTotal }}</template>
· step {{ editStep }}/{{ editMaxStep }}
</p>
<p v-else class="text-xs text-zinc-500">
@@ -683,8 +717,24 @@
<span :class="editStatusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ editStatusMessage }}</span>
<span class="text-amber-200">{{ editProgress }}%</span>
</div>
<p v-if="editChainTotal > 1" class="mb-2 text-xs text-amber-200/80">
Step {{ editChainStep }}/{{ editChainTotal }}{{ editChainLabel ? `: ${editChainLabel}` : '' }}
<template v-if="editOverallProgress"> · {{ editOverallProgress }}% overall</template>
</p>
<ol v-if="editBusy && editActiveChainPlan.length > 1" class="mb-3 space-y-1">
<li
v-for="(part, index) in editActiveChainPlan"
:key="index"
class="flex items-center gap-2 rounded-lg px-2 py-1 text-xs"
:class="editChainPartClass(index)"
>
<span class="h-2 w-2 shrink-0 rounded-full" :class="editChainPartDot(index)" />
<span class="shrink-0 font-medium">{{ part.label }}</span>
<span class="min-w-0 truncate text-zinc-400">{{ part.prompt }}</span>
</li>
</ol>
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: editProgress + '%' }" />
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: (editOverallProgress || editProgress) + '%' }" />
</div>
</div>
<div
@@ -743,7 +793,10 @@
<div class="glass rounded-3xl p-5 sm:p-6">
<div class="flex items-center justify-between gap-3">
<h2 class="font-display text-xl font-bold">Library</h2>
<div>
<h2 class="font-display text-xl font-bold">Library</h2>
<p class="mt-1 text-xs text-zinc-500">Generated outputs only. Uploaded stills live in the image picker.</p>
</div>
<button
v-if="browseFolder?.protected && browseFolder.unlocked"
type="button"
@@ -755,7 +808,7 @@
</div>
<form v-if="browseFolder?.protected && !browseFolder.unlocked" class="mt-4 space-y-3" @submit.prevent="unlockFolder(browseFolderId)">
<p class="text-sm text-zinc-400">This folder is locked. Unlock to view {{ browseFolder.clipCount || 0 }} clip{{ browseFolder.clipCount === 1 ? '' : 's' }}.</p>
<p class="text-sm text-zinc-400">This folder is locked. Unlock to view {{ folderOutputCount(browseFolder) }} output{{ folderOutputCount(browseFolder) === 1 ? '' : 's' }}.</p>
<input v-model="unlockPassword" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Folder password">
<button type="submit" class="w-full rounded-xl bg-amber-400 px-4 py-2 font-semibold text-zinc-950">Unlock</button>
</form>
@@ -771,25 +824,39 @@
@click="browseFolderId = folder.id"
>
{{ folder.name }}
<span class="text-zinc-500">({{ folder.unlocked ? clipsIn(folder.id).length : folder.clipCount }})</span>
<span class="text-zinc-500">({{ folderOutputCount(folder) }})</span>
<span v-if="folder.protected">{{ folder.unlocked ? '' : ' · lock' }}</span>
</button>
</div>
<p v-if="!libraryGroups.length" class="mt-4 text-sm text-zinc-500">Nothing in this folder yet. Generate into it from the left.</p>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="option in [{ id: 'all', label: 'All' }, { id: 'images', label: 'Images' }, { id: 'videos', label: 'Videos' }] as const"
:key="option.id"
type="button"
class="rounded-full border px-3 py-1 text-xs"
:class="libraryFilter === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="libraryFilter = option.id"
>
{{ option.label }}
</button>
</div>
<p v-if="!libraryEntries.length" class="mt-4 text-sm text-zinc-500">{{ libraryEmptyLabel }}</p>
<ul class="mt-4 space-y-3">
<li
v-for="group in libraryGroups"
:key="group.key"
v-for="entry in libraryEntries"
:key="entry.key"
class="rounded-2xl border border-white/5 bg-zinc-950/40 p-3"
>
<template v-if="entry.kind === 'clip'">
<div class="flex gap-3">
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openClip(group.head)">
<span v-if="group.head.hideThumbnail" class="sensitive-glow block h-full w-full" />
<img v-else-if="group.head.hasThumbnail" :src="`/api/library/clips/${group.head.id}/thumb`" alt="" class="h-full w-full object-cover">
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openClip(entry.group.head)">
<span v-if="entry.group.head.hideThumbnail" class="sensitive-glow block h-full w-full" />
<img v-else-if="entry.group.head.hasThumbnail" :src="`/api/library/clips/${entry.group.head.id}/thumb`" alt="" class="h-full w-full object-cover">
<video
v-else
:src="`/api/library/clips/${group.head.id}/video#t=0.1`"
:src="`/api/library/clips/${entry.group.head.id}/video#t=0.1`"
class="h-full w-full object-cover"
muted
playsinline
@@ -799,23 +866,23 @@
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<input
v-if="renamingId === group.head.id && renamingFamily"
v-if="renamingId === entry.group.head.id && renamingFamily"
ref="renameInput"
v-model="renameDraft"
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
maxlength="80"
@keydown.enter.prevent="saveRename(group.head, true)"
@keydown.enter.prevent="saveRename(entry.group.head, true)"
@keydown.escape.prevent="cancelRename"
@blur="saveRename(group.head, true)"
@blur="saveRename(entry.group.head, true)"
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ collectionTitle(group) }}</p>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ collectionTitle(entry.group) }}</p>
<div class="group relative shrink-0">
<button
type="button"
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
:aria-label="'Copy prompt for ' + collectionTitle(group)"
:aria-label="'Copy prompt for ' + collectionTitle(entry.group)"
title="Copy prompt"
@click.stop="copyPrompt(familyScript(group.parts))"
@click.stop="copyPrompt(familyScript(entry.group.parts))"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
<circle cx="12" cy="12" r="10" />
@@ -823,41 +890,41 @@
</svg>
</button>
<div class="pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible">
{{ familyScript(group.parts) }}
{{ familyScript(entry.group.parts) }}
</div>
</div>
</div>
<p class="text-xs text-zinc-500">{{ clipMetaLine(group.head) }}</p>
<p class="text-xs text-zinc-500">{{ clipMetaLine(entry.group.head) }}</p>
<div class="mt-2 flex flex-wrap items-center gap-3">
<button v-if="group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(group.key)">
{{ expandedFamilies[group.key] ? 'Collapse' : `Expand ${group.parts.length} parts` }}
<button v-if="entry.group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.group.key)">
{{ expandedFamilies[entry.group.key] ? 'Collapse' : `Expand ${entry.group.parts.length} parts` }}
</button>
<button type="button" class="text-xs text-amber-300" @click="openClip(group.head)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="openExtend(group.head)">Extend</button>
<button type="button" class="text-xs text-amber-300" @click="startRename(group.head, true)">Rename</button>
<button type="button" class="text-xs text-amber-300" @click="rerun(group.head, true)">Rerun</button>
<button type="button" class="text-xs text-amber-300" @click="openClip(entry.group.head)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="openExtend(entry.group.head)">Extend</button>
<button type="button" class="text-xs text-amber-300" @click="startRename(entry.group.head, true)">Rename</button>
<button type="button" class="text-xs text-amber-300" @click="rerun(entry.group.head, true)">Rerun</button>
<select
v-if="folders.length > 1"
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
:value="''"
@change="onMoveClip(group.head, $event, true)"
@change="onMoveClip(entry.group.head, $event, true)"
>
<option disabled value="">Move to…</option>
<option
v-for="folder in folders.filter(folder => folder.id !== group.head.folderId)"
v-for="folder in folders.filter(folder => folder.id !== entry.group.head.folderId)"
:key="folder.id"
:value="folder.id"
>
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
</option>
</select>
<button type="button" class="text-xs text-red-300" @click="removeClip(group.head, true)">Delete</button>
<button type="button" class="text-xs text-red-300" @click="removeClip(entry.group.head, true)">Delete</button>
</div>
</div>
</div>
<ul v-if="expandedFamilies[group.key] && group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
<ul v-if="expandedFamilies[entry.group.key] && entry.group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
<li
v-for="item in chronologicalParts(group.parts)"
v-for="item in chronologicalParts(entry.group.parts)"
:key="item.id"
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
>
@@ -912,6 +979,130 @@
</div>
</li>
</ul>
</template>
<template v-else-if="entry.kind === 'still' && entry.stillGroup">
<div class="flex gap-3">
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openStill(entry.stillGroup.head)">
<span v-if="entry.stillGroup.head.hideInput" class="sensitive-glow block h-full w-full" />
<img
v-else
:src="`/api/library/stills/${entry.stillGroup.head.id}`"
alt=""
class="h-full w-full object-cover"
>
</button>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<input
v-if="renamingId === entry.stillGroup.head.id && renamingFamily"
v-model="renameDraft"
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
maxlength="80"
@keydown.enter.prevent="saveRenameStill(entry.stillGroup.head, true)"
@keydown.escape.prevent="cancelRename"
@blur="saveRenameStill(entry.stillGroup.head, true)"
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillCollectionTitle(entry.stillGroup) }}</p>
<div class="group relative shrink-0">
<button
type="button"
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
:aria-label="'Copy prompt for ' + stillCollectionTitle(entry.stillGroup)"
title="Copy prompt"
@click.stop="copyPrompt(stillFamilyScript(entry.stillGroup.parts))"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" stroke-linecap="round" />
</svg>
</button>
<div class="pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible">
{{ stillFamilyScript(entry.stillGroup.parts) }}
</div>
</div>
</div>
<p class="text-xs text-zinc-500">{{ stillMetaLine(entry.stillGroup.head) }}</p>
<div class="mt-2 flex flex-wrap items-center gap-3">
<button v-if="entry.stillGroup.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.stillGroup.key)">
{{ expandedFamilies[entry.stillGroup.key] ? 'Collapse' : `Expand ${entry.stillGroup.parts.length} passes` }}
</button>
<button type="button" class="text-xs text-amber-300" @click="openStill(entry.stillGroup.head)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(entry.stillGroup.head)">Use as input</button>
<a
:href="`/api/library/stills/${entry.stillGroup.head.id}`"
:download="stillTitle(entry.stillGroup.head)"
class="text-xs text-amber-300"
>Download</a>
<button type="button" class="text-xs text-amber-300" @click="startRenameStill(entry.stillGroup.head, true)">Rename</button>
<select
v-if="folders.length > 1"
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
:value="''"
@change="onMoveStill(entry.stillGroup.head, $event, true)"
>
<option disabled value="">Move to…</option>
<option
v-for="folder in folders.filter(folder => folder.id !== entry.stillGroup.head.folderId)"
:key="folder.id"
:value="folder.id"
>
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
</option>
</select>
<button type="button" class="text-xs text-red-300" @click="removeStill(entry.stillGroup.head, true)">Delete</button>
</div>
</div>
</div>
<ul v-if="expandedFamilies[entry.stillGroup.key] && entry.stillGroup.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
<li
v-for="item in chronologicalStills(entry.stillGroup.parts)"
:key="item.id"
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
>
<button type="button" class="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-zinc-900" @click="openStill(item)">
<span v-if="item.hideInput" class="sensitive-glow block h-full w-full" />
<img v-else :src="`/api/library/stills/${item.id}`" alt="" class="h-full w-full object-cover">
</button>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<input
v-if="renamingId === item.id && !renamingFamily"
v-model="renameDraft"
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
maxlength="80"
@keydown.enter.prevent="saveRenameStill(item)"
@keydown.escape.prevent="cancelRename"
@blur="saveRenameStill(item)"
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillTitle(item) }}</p>
</div>
<p class="text-xs text-zinc-500">{{ stillMetaLine(item) }}</p>
<div class="mt-1.5 flex flex-wrap items-center gap-3">
<button type="button" class="text-xs text-amber-300" @click="openStill(item)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(item)">Use as input</button>
<a :href="`/api/library/stills/${item.id}`" :download="stillTitle(item)" class="text-xs text-amber-300">Download</a>
<button type="button" class="text-xs text-amber-300" @click="startRenameStill(item)">Rename</button>
<select
v-if="folders.length > 1"
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
:value="''"
@change="onMoveStill(item, $event)"
>
<option disabled value="">Move to…</option>
<option
v-for="folder in folders.filter(folder => folder.id !== item.folderId)"
:key="folder.id"
:value="folder.id"
>
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
</option>
</select>
<button type="button" class="text-xs text-red-300" @click="removeStill(item)">Delete</button>
</div>
</div>
</li>
</ul>
</template>
</li>
</ul>
</template>
@@ -967,6 +1158,7 @@ interface LibraryFolder {
protected: boolean
unlocked: boolean
clipCount?: number
imageCount?: number
}
interface LibraryStill {
@@ -977,6 +1169,12 @@ interface LibraryStill {
height: number
createdAt: number
hideInput?: boolean
role?: 'input' | 'output'
name?: string
prompt?: string
familyId?: string
parentStillId?: string
chainIndex?: number
}
interface LibraryClip {
@@ -1040,6 +1238,11 @@ interface QueuedExtension {
duration: number
}
interface QueuedEditPass {
id: string
prompt: string
}
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
'16:9': { width: 1344, height: 768 },
'9:16': { width: 768, height: 1344 },
@@ -1061,6 +1264,7 @@ const dragging = ref(false)
const prompt = ref('')
const shotScriptMode = ref(false)
const extensionQueue = ref<QueuedExtension[]>([])
const editPassQueue = ref<QueuedEditPass[]>([])
const clipName = ref('')
const videoDownloadName = ref('aigen')
const editDownloadName = ref('aigen-edit')
@@ -1128,6 +1332,12 @@ const editCfg = ref(1)
const editNegative = ref('')
const editRefFile = ref<File | null>(null)
const editRefPreview = ref('')
const editChainStep = ref(1)
const editChainTotal = ref(1)
const editChainLabel = ref('')
const editOverallProgress = ref(0)
const editCompletedChainStep = ref(0)
const editActiveChainPlan = ref<{ label: string; prompt: string }[]>([])
const videoJobId = ref('')
const editJobId = ref('')
const currentClipId = ref('')
@@ -1165,6 +1375,7 @@ const unlockPassword = ref('')
const settingsOpen = ref(false)
const pickerOpen = ref(false)
const pickerSlot = ref<'main' | 'editRef' | number>('main')
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
const videoWorkflow = ref('v1')
const useIdentityRefs = ref(false)
const identityRefs = ref<(File | null)[]>([null, null, null, null])
@@ -1305,6 +1516,7 @@ const videoGenerateDisabled = computed(() => {
const editBlocked = computed(() => {
if (editBusy.value) return true
if (!file.value || !prompt.value.trim() || !folderId.value) return true
if (editPassQueue.value.some(item => !item.prompt.trim())) return true
if (!comfyOk.value && !imageComfyOk.value) return true
return false
})
@@ -1319,6 +1531,11 @@ const generateLabel = computed(() => {
if (!n) return 'Generate Video'
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
})
const editLabel = computed(() => {
const n = editPassQueue.value.length
if (!n) return 'Edit image'
return n === 1 ? 'Edit image + 1 pass' : `Edit image + ${n} passes`
})
const parsedShots = computed(() => parseShotScript(prompt.value))
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value)
const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : []))
@@ -1364,6 +1581,22 @@ const plannedChain = computed(() => {
})
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value && clip.hasVideo !== false))
const visibleOutputStills = computed(() => stills.value.filter(still => still.folderId === browseFolderId.value && still.role === 'output'))
const stillGroups = computed(() => {
const groups = new Map<string, LibraryStill[]>()
for (const still of visibleOutputStills.value) {
const key = stillFamilyKey(still)
const list = groups.get(key) || []
list.push(still)
groups.set(key, list)
}
return [...groups.entries()]
.map(([key, parts]) => {
const sorted = [...parts].sort(sortStillParts)
return { key, head: sorted[0], parts: sorted }
})
.sort((a, b) => b.head.createdAt - a.head.createdAt)
})
const libraryGroups = computed(() => {
const groups = new Map<string, LibraryClip[]>()
for (const clip of visibleClips.value) {
@@ -1379,13 +1612,50 @@ const libraryGroups = computed(() => {
})
.sort((a, b) => b.head.createdAt - a.head.createdAt)
})
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
const libraryEntries = computed(() => {
const videos = libraryFilter.value === 'images'
? []
: libraryGroups.value.map(group => ({
kind: 'clip' as const,
key: group.key,
createdAt: group.head.createdAt,
group
}))
const images = libraryFilter.value === 'videos'
? []
: stillGroups.value.map(group => ({
kind: 'still' as const,
key: group.key,
createdAt: group.head.createdAt,
stillGroup: group
}))
return [...videos, ...images].sort((a, b) => b.createdAt - a.createdAt)
})
const libraryEmptyLabel = computed(() => {
if (libraryFilter.value === 'images') return 'No generated stills in this folder yet.'
if (libraryFilter.value === 'videos') return 'No generated clips in this folder yet.'
return 'Nothing in this folder yet. Generate into it from the left.'
})
const browseFolder = computed(() => folders.value.find(folder => folder.id === browseFolderId.value))
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
function clipsIn(id: string) {
return clips.value.filter(clip => clip.folderId === id)
}
function stillsIn(id: string) {
return stills.value.filter(still => still.folderId === id && still.role === 'output')
}
function folderOutputCount(folder: LibraryFolder) {
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0)
const videos = clipsIn(folder.id).length
const images = stillsIn(folder.id).length
if (libraryFilter.value === 'videos') return videos
if (libraryFilter.value === 'images') return images
return videos + images
}
function familyBaseName(name: string) {
return String(name || '').trim().replace(/\s+p\d+$/i, '')
}
@@ -1397,6 +1667,25 @@ function clipFamilyKey(clip: LibraryClip) {
return `solo:${clip.id}`
}
function stillFamilyKey(still: LibraryStill) {
if (still.familyId) return `still-family:${still.folderId}:${still.familyId}`
return `still-solo:${still.id}`
}
function sortStillParts(a: LibraryStill, b: LibraryStill) {
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
if (chainDelta) return chainDelta
return b.createdAt - a.createdAt
}
function chronologicalStills(parts: LibraryStill[]) {
return [...parts].sort((a, b) => {
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
if (chain) return chain
return a.createdAt - b.createdAt
})
}
function sortClipParts(a: LibraryClip, b: LibraryClip) {
const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0)
if (durationDelta) return durationDelta
@@ -1440,6 +1729,31 @@ function clipMetaLine(item: LibraryClip) {
return duration ? `${res} · ${duration} · ${steps} · ${seed}` : `${res} · ${steps} · ${seed}`
}
function stillTitle(still: LibraryStill) {
return still.name || still.filename.replace(/\.[a-z0-9]+$/i, '') || 'Edited still'
}
function stillCollectionTitle(group: { head: LibraryStill; parts: LibraryStill[] }) {
const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name)
return named || stillTitle(group.head)
}
function stillFamilyScript(parts: LibraryStill[]) {
const ordered = chronologicalStills(parts)
if (ordered.length <= 1) return ordered[0]?.prompt || ''
return ordered.map((part, index) => (
index === 0 ? (part.prompt || '').trim() : `pass ${index + 1}\n${(part.prompt || '').trim()}`
)).join('\n\n')
}
function stillMetaLine(still: LibraryStill) {
const res = still.width && still.height ? `${still.width}×${still.height}` : 'image'
const pass = typeof still.chainIndex === 'number' ? `pass ${still.chainIndex + 1}` : ''
if (pass && still.prompt) return `${res} · ${pass} · ${still.prompt}`
if (still.prompt) return `${res} · ${still.prompt}`
return pass ? `${res} · ${pass}` : res
}
function toggleFamily(key: string) {
expandedFamilies.value = { ...expandedFamilies.value, [key]: !expandedFamilies.value[key] }
}
@@ -1464,6 +1778,26 @@ function chainPartDot(index: number) {
return 'bg-zinc-600'
}
function editChainPartDone(index: number) {
return index < editCompletedChainStep.value
}
function editChainPartCurrent(index: number) {
return editBusy.value && index === editCompletedChainStep.value
}
function editChainPartClass(index: number) {
if (editChainPartDone(index)) return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
if (editChainPartCurrent(index)) return 'border border-amber-300/40 bg-amber-400/10 text-amber-100'
return 'border border-white/5 text-zinc-400'
}
function editChainPartDot(index: number) {
if (editChainPartDone(index)) return 'bg-emerald-400'
if (editChainPartCurrent(index)) return 'bg-amber-300 animate-pulse'
return 'bg-zinc-600'
}
function applyThumbnailDefault() {
if (!showPrivacyToggles.value) {
hideThumbnail.value = false
@@ -2177,6 +2511,7 @@ async function generateExtension() {
}
function openClip(item: LibraryClip) {
studioMode.value = 'video'
closeExtend()
watchingLibrary.value = true
currentClipId.value = item.id
@@ -2193,6 +2528,117 @@ function openClip(item: LibraryClip) {
}
}
function openStill(still: LibraryStill) {
studioMode.value = 'edit'
browseFolderId.value = still.folderId
editResultUrl.value = `/api/library/stills/${still.id}`
editAwaitingReveal.value = still.hideInput === true
editLockedSave.value = false
editDownloadName.value = clipFileName(stillTitle(still))
if (!editBusy.value) {
editStatusMessage.value = still.hideInput ? 'Hidden still ready' : 'Loaded from library'
editProgress.value = 100
}
}
async function useStillAsInput(still: LibraryStill) {
try {
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
readFile(new File([blob], still.filename, { type: blob.type || 'image/png' }))
toast('Loaded as input. It stays in Output, not the upload library.')
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not load that still')
}
}
async function removeStill(still: LibraryStill, family = false) {
const watched = family ? stillFamilyPartsFor(still) : [still]
const label = family && watched.length > 1
? `this ${watched.length}-pass edit`
: `“${stillTitle(still)}”`
if (!confirm(`Remove ${label} from this folder?`)) return
try {
await $fetch(`/api/library/stills/${still.id}`, {
method: 'DELETE',
query: family ? { family: '1' } : undefined
})
const ids = new Set(watched.map(item => item.id))
if ([...ids].some(id => editResultUrl.value.includes(id))) {
editResultUrl.value = ''
editAwaitingReveal.value = false
editLockedSave.value = false
}
await loadLibrary()
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not delete image')
}
}
async function onMoveStill(still: LibraryStill, event: Event, family = false) {
const select = event.target as HTMLSelectElement
const destId = select.value
select.value = ''
if (!destId || destId === still.folderId) return
try {
applyLibrary(await $fetch(`/api/library/stills/${still.id}`, {
method: 'PATCH',
body: { folderId: destId, family: family || undefined }
}))
const dest = folders.value.find(folder => folder.id === destId)
if (dest?.unlocked) browseFolderId.value = destId
editStatusMessage.value = dest?.unlocked
? `Moved ${family ? 'edit chain' : 'still'} to ${dest.name}`
: `Moved ${family ? 'edit chain' : 'still'} to ${dest?.name || 'folder'} (locked)`
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not move image')
}
}
function startRenameStill(still: LibraryStill, family = false) {
renamingId.value = still.id
renamingFamily.value = family
const source = family
? (familyBaseName(still.name || '') || still.name || '')
: (still.name || stillTitle(still))
renameDraft.value = source.trim() || 'edited'
nextTick(() => {
renameInput.value?.focus()
renameInput.value?.select()
})
}
async function saveRenameStill(still: LibraryStill, family = false) {
if (renamingId.value !== still.id || renamingFamily.value !== family) return
const name = renameDraft.value.trim().slice(0, 80)
if (!name) {
toast('A name is required')
return
}
const current = family ? familyBaseName(still.name || '') : still.name
if (name === current) {
cancelRename()
return
}
try {
applyLibrary(await $fetch(`/api/library/stills/${still.id}`, {
method: 'PATCH',
body: { name, family: family || undefined }
}))
cancelRename()
if (editResultUrl.value.includes(still.id)) {
editDownloadName.value = clipFileName(name)
}
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not rename image')
}
}
function stillFamilyPartsFor(still: LibraryStill) {
const group = stillGroups.value.find(entry => entry.parts.some(part => part.id === still.id))
return chronologicalStills(group?.parts || [still])
}
async function removeClip(item: LibraryClip, family = false) {
const watched = family ? familyPartsFor(item) : [item]
try {
@@ -2305,12 +2751,28 @@ function removeQueuedExtension(id: string) {
extensionQueue.value = extensionQueue.value.filter(item => item.id !== id)
}
function queueEditPass() {
editPassQueue.value.push({
id: crypto.randomUUID(),
prompt: ''
})
}
function removeEditPass(id: string) {
editPassQueue.value = editPassQueue.value.filter(item => item.id !== id)
}
async function editImage() {
if (!file.value || !prompt.value.trim()) return
if (editPassQueue.value.some(item => !item.prompt.trim())) {
toast('Each extra pass needs a prompt.')
return
}
if (!folderId.value) {
toast('Choose a library folder before editing.')
return
}
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
editStatusBusy.value = false
editBusy.value = true
stopListen('edit')
@@ -2321,6 +2783,15 @@ async function editImage() {
editLockedSave.value = false
editStatusMessage.value = 'Checking Beast ComfyUI...'
editMaxStep.value = editSteps.value
editChainStep.value = 1
editChainTotal.value = 1 + extraPasses.length
editChainLabel.value = extraPasses.length ? 'Pass 1' : ''
editOverallProgress.value = 0
editCompletedChainStep.value = 0
editActiveChainPlan.value = [
{ label: 'Pass 1', prompt: prompt.value.trim() },
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
]
startTimer('edit')
try {
const body = new FormData()
@@ -2334,6 +2805,7 @@ async function editImage() {
body.append('folderId', folderId.value)
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('name', clipName.value.trim())
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
editDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit')
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/edit', {
method: 'POST',
@@ -2482,17 +2954,55 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
if (typeof payload.progress === 'number') editProgress.value = payload.progress
if (typeof payload.step === 'number') editStep.value = payload.step
if (typeof payload.maxStep === 'number') editMaxStep.value = payload.maxStep
if (typeof payload.chainStep === 'number') editChainStep.value = payload.chainStep
if (typeof payload.chainTotal === 'number') editChainTotal.value = payload.chainTotal
if (payload.chainLabel) editChainLabel.value = String(payload.chainLabel)
if (typeof payload.overallProgress === 'number') editOverallProgress.value = payload.overallProgress
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
const complete = payload.type === 'complete' || payload.status === 'complete'
if (editSettledUi) return
const dest = folders.value.find(folder => folder.id === folderId.value)
const locked = dest
? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === true)
if (payload.stillId && !locked) {
editResultUrl.value = `/api/library/stills/${payload.stillId}`
editLockedSave.value = false
if (payload.hideThumbnail || hidden) editAwaitingReveal.value = payload.type !== 'checkpoint'
}
if (payload.type === 'checkpoint' && payload.stillId) {
editCompletedChainStep.value = Math.max(editCompletedChainStep.value, Number(payload.chainStep || editChainStep.value) || 0)
loadLibrary().catch(() => null)
return
}
if (complete && payload.stillId) {
const total = Math.max(1, Number(payload.chainTotal || 0) || 0, Number(editChainTotal.value || 0) || 0)
const step = Math.max(1, Number(payload.chainStep || 0) || 0, Number(editChainStep.value || 0) || 0)
if (total > 1 && step < total) {
editCompletedChainStep.value = Math.max(editCompletedChainStep.value, step)
loadLibrary().catch(() => null)
if (locked) {
editResultUrl.value = ''
editLockedSave.value = true
}
editStatusMessage.value = `Pass ${step} of ${total} saved, but the remaining passes did not start.`
editProgress.value = Math.min(Number(payload.progress || editProgress.value || 99), 99)
toast(`Pass ${step} of ${total} saved. Remaining passes did not run.`)
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
stopListen('edit')
clearActiveJob('edit')
void pollComfyHealth()
editSettledUi = true
return
}
editSettledUi = true
const dest = folders.value.find(folder => folder.id === folderId.value)
const locked = dest
? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === true)
if (locked) {
editResultUrl.value = ''
editLockedSave.value = true
@@ -2502,7 +3012,8 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
editResultUrl.value = `/api/library/stills/${payload.stillId}`
editAwaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
editLockedSave.value = false
editStatusMessage.value = 'Edit finished'
editStatusMessage.value = total > 1 ? 'Edit chain finished' : 'Edit finished'
editCompletedChainStep.value = total
}
editBusy.value = false
editStatusBusy.value = false