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