Show a single Live GPU status, keep job progress visible across input tabs, and let the library move or delete in batches without switching folders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 10:49:48 -05:00
co-authored by Cursor
parent e715c77605
commit d50db8e180
+333 -131
View File
@@ -6,24 +6,17 @@
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</div> <div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</div>
<div> <div>
<h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1> <h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1>
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">MiniMax H3 · Flux.2 Klein · ComfyUI</p> <p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">MiniMax H3 · Flux.2 Klein</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-3 text-sm"> <div class="flex items-center gap-3 text-sm">
<span <span
class="items-center gap-2 rounded-full border px-3 py-1" class="hidden items-center gap-2 rounded-full border px-3 py-1 sm:inline-flex"
:class="[studioMode === 'video' ? 'hidden sm:inline-flex' : 'hidden xl:inline-flex opacity-60', comfyBadge.className]" :class="rigBadge.className"
:title="rigBadge.title"
> >
<span class="h-2 w-2 rounded-full" :class="comfyBadge.dot" /> <span class="h-2 w-2 rounded-full" :class="rigBadge.dot" />
{{ comfyBadge.label }} {{ rigBadge.label }}
</span>
<span
class="items-center gap-2 rounded-full border px-3 py-1"
:class="[studioMode === 'edit' ? 'hidden sm:inline-flex' : 'hidden xl:inline-flex opacity-60', imageComfyBadge.className]"
:title="imageComfyHost || 'Image edit on Beast'"
>
<span class="h-2 w-2 rounded-full" :class="imageComfyBadge.dot" />
{{ imageComfyBadge.label }}
</span> </span>
<span class="hidden md:block text-zinc-400">{{ userName }}</span> <span class="hidden md:block text-zinc-400">{{ userName }}</span>
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true"> <button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">
@@ -42,7 +35,7 @@
type="button" type="button"
class="rounded-full border px-3 py-1 text-xs font-medium" class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'video' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'" :class="studioMode === 'video' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'video'" @click="studioMode = 'video'; outputFocus = 'video'"
> >
Video Video
</button> </button>
@@ -50,15 +43,15 @@
type="button" type="button"
class="rounded-full border px-3 py-1 text-xs font-medium" class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'edit' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'" :class="studioMode === 'edit' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'edit'" @click="studioMode = 'edit'; outputFocus = 'edit'"
> >
Image edit Image edit
</button> </button>
</div> </div>
<h2 class="font-display text-2xl font-bold">Input</h2> <h2 class="font-display text-2xl font-bold">Input</h2>
<p class="text-sm text-zinc-400">{{ studioMode === 'edit' <p class="text-sm text-zinc-400">{{ studioMode === 'edit'
? 'Drop the still to edit. An optional second still applies image 2 onto image 1. Both run on Beast; files are removed from the desktop after save.' ? 'Drop the still to edit. An optional second still applies image 2 onto image 1. Files leave the desktop after save.'
: 'Drop a still, describe the motion, then relay the job to the local desktop.' }}</p> : 'Drop a still, describe the motion, then send the job to the desktop GPU.' }}</p>
</div> </div>
<div <div
@@ -502,11 +495,7 @@
<summary class="cursor-pointer text-sm font-medium">Image edit settings</summary> <summary class="cursor-pointer text-sm font-medium">Image edit settings</summary>
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
<p class="text-xs text-zinc-500"> <p class="text-xs text-zinc-500">
Flux.2 Klein 9B on Beast. Sidecar is off. Flux.2 Klein 9B. Shares the desktop GPU with video.
<span class="text-zinc-200">{{ imageComfyBadge.label }}</span>
</p>
<p class="text-[11px] text-zinc-500">
Beast {{ imageBeastHost || imageComfyHost || 'unset' }}{{ comfyOk ? (comfyBusy ? ' · busy' : ' · free') : ' · offline' }}
</p> </p>
<label class="block text-sm"> <label class="block text-sm">
<span class="mb-1 block text-zinc-400">Negative prompt</span> <span class="mb-1 block text-zinc-400">Negative prompt</span>
@@ -540,7 +529,7 @@
{{ studioMode === 'edit' ? (editBusy ? 'Editing…' : editLabel) : generateLabel }} {{ studioMode === 'edit' ? (editBusy ? 'Editing…' : editLabel) : generateLabel }}
</button> </button>
<button <button
v-if="studioMode === 'edit' ? editBusy : videoBusy" v-if="videoBusy || editBusy"
type="button" type="button"
class="rounded-2xl border border-red-400/40 px-4 py-3 text-red-300" class="rounded-2xl border border-red-400/40 px-4 py-3 text-red-300"
@click="cancelJob" @click="cancelJob"
@@ -572,32 +561,45 @@
<section class="space-y-5"> <section class="space-y-5">
<div ref="outputPanel" class="glass rounded-3xl p-5 sm:p-6"> <div ref="outputPanel" 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">
<div class="flex items-center gap-3">
<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"> <button
{{ editElapsedLabel }} v-if="canClearPreview"
<template v-if="editChainTotal > 1"> · chain {{ editChainStep }}/{{ editChainTotal }}</template> type="button"
· step {{ editStep }}/{{ editMaxStep }} class="rounded-full border border-white/15 px-2.5 py-0.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white"
</p> @click="clearPreview"
<p v-else class="text-xs text-zinc-500"> >
{{ elapsedLabel }} Clear preview
<template v-if="chainTotal > 1"> · chain {{ chainStep }}/{{ chainTotal }}</template> </button>
· step {{ step }}/{{ maxStep || defaultVideoSteps(turbo, videoWorkflow) }} </div>
<p class="text-xs text-zinc-500">
{{ liveElapsedLabel }}
<template v-if="outputProgressKind === 'edit' && editChainTotal > 1"> · chain {{ editChainStep }}/{{ editChainTotal }}</template>
<template v-else-if="outputProgressKind === 'video' && chainTotal > 1"> · chain {{ chainStep }}/{{ chainTotal }}</template>
· step {{ liveStep }}/{{ liveMaxStep }}
</p> </p>
</div> </div>
<p v-if="studioMode === 'edit' && videoBusy" class="mt-2 text-xs text-zinc-500">Video is using Beast. This edit waits in the Comfy queue.</p>
<p v-else-if="studioMode === 'video' && editBusy" class="mt-2 text-xs text-zinc-500">Image edit is using Beast. Video waits until that finishes.</p>
<div v-show="studioMode === 'video'">
<div class="mt-4"> <div class="mt-4">
<div class="mb-2 flex items-center justify-between text-sm"> <div class="mb-2 flex items-center justify-between gap-3 text-sm">
<span :class="statusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ statusMessage }}</span> <span class="min-w-0 truncate" :class="liveStatusBusy ? 'text-amber-200' : 'text-zinc-300'">
<span class="text-amber-200">{{ progress }}%</span> <span
v-if="outputProgressKind !== studioMode"
class="mr-2 inline-flex rounded-full border border-amber-300/30 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-200"
>{{ outputProgressKind === 'edit' ? 'Edit' : 'Video' }}</span>
{{ liveStatusMessage }}
</span>
<span class="shrink-0 text-amber-200">{{ liveProgressPct }}%</span>
</div> </div>
<p v-if="chainTotal > 1" class="mb-2 text-xs text-amber-200/80"> <p v-if="outputProgressKind === 'video' && chainTotal > 1" class="mb-2 text-xs text-amber-200/80">
Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }} Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }}
<template v-if="overallProgress"> · {{ overallProgress }}% overall</template> <template v-if="overallProgress"> · {{ overallProgress }}% overall</template>
</p> </p>
<ol v-if="videoBusy && activeChainPlan.length > 1" class="mb-3 space-y-1"> <p v-else-if="outputProgressKind === 'edit' && 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="outputProgressKind === 'video' && videoBusy && activeChainPlan.length > 1" class="mb-3 space-y-1">
<li <li
v-for="(part, index) in activeChainPlan" v-for="(part, index) in activeChainPlan"
:key="index" :key="index"
@@ -610,10 +612,24 @@
<span class="shrink-0 text-zinc-500">{{ formatClipDuration(part.duration) }}</span> <span class="shrink-0 text-zinc-500">{{ formatClipDuration(part.duration) }}</span>
</li> </li>
</ol> </ol>
<ol v-else-if="outputProgressKind === 'edit' && 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: progress + '%' }" /> <div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: liveProgressPct + '%' }" />
</div> </div>
</div> </div>
<div v-show="outputPreviewKind === 'video'">
<div <div
class="relative mt-5 overflow-hidden rounded-2xl bg-black/50" class="relative mt-5 overflow-hidden rounded-2xl bg-black/50"
:class="{ 'sensitive-glow': awaitingReveal || lockedSave }" :class="{ 'sensitive-glow': awaitingReveal || lockedSave }"
@@ -711,32 +727,7 @@
</div> </div>
</div> </div>
<div v-show="studioMode === 'edit'"> <div v-show="outputPreviewKind === 'edit'">
<div class="mt-4">
<div class="mb-2 flex items-center justify-between text-sm">
<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: (editOverallProgress || editProgress) + '%' }" />
</div>
</div>
<div <div
class="relative mt-5 overflow-hidden rounded-2xl bg-black/50" class="relative mt-5 overflow-hidden rounded-2xl bg-black/50"
:class="{ 'sensitive-glow': editAwaitingReveal || editLockedSave }" :class="{ 'sensitive-glow': editAwaitingReveal || editLockedSave }"
@@ -793,10 +784,16 @@
<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">
<div>
<h2 class="font-display text-xl font-bold">Library</h2> <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 class="flex items-center gap-3">
</div> <button
v-if="libraryEntries.length"
type="button"
class="text-xs text-zinc-400 hover:text-white"
@click="selectedKeys.length === libraryEntries.length ? clearSelection() : selectAllLibrary()"
>
{{ selectedKeys.length && selectedKeys.length === libraryEntries.length ? 'Clear selection' : 'Select all' }}
</button>
<button <button
v-if="browseFolder?.protected && browseFolder.unlocked" v-if="browseFolder?.protected && browseFolder.unlocked"
type="button" type="button"
@@ -806,6 +803,7 @@
Lock folder Lock folder
</button> </button>
</div> </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 {{ folderOutputCount(browseFolder) }} output{{ folderOutputCount(browseFolder) === 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>
@@ -814,43 +812,76 @@
</form> </form>
<template v-else> <template v-else>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap items-center justify-between gap-2">
<div class="flex min-w-0 flex-wrap gap-1.5">
<button <button
v-for="folder in folders" v-for="folder in folders"
:key="folder.id" :key="folder.id"
type="button" type="button"
class="rounded-full border px-3 py-1 text-xs" class="rounded-full border px-2.5 py-1 text-[11px]"
:class="browseFolderId === folder.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'" :class="browseFolderId === folder.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="browseFolderId = folder.id" @click="browseFolderId = folder.id"
> >
{{ folder.name }} {{ folder.name }}
<span class="text-zinc-500">({{ folderOutputCount(folder) }})</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>
<div class="inline-flex rounded-full border border-white/10 p-0.5">
<div class="mt-3 flex flex-wrap gap-2">
<button <button
v-for="option in [{ id: 'all', label: 'All' }, { id: 'images', label: 'Images' }, { id: 'videos', label: 'Videos' }] as const" v-for="option in [{ id: 'all', label: 'All' }, { id: 'images', label: 'Images' }, { id: 'videos', label: 'Videos' }] as const"
:key="option.id" :key="option.id"
type="button" type="button"
class="rounded-full border px-3 py-1 text-xs" class="rounded-full px-2.5 py-1 text-[11px]"
:class="libraryFilter === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400'" :class="libraryFilter === option.id ? 'bg-amber-400/15 text-amber-100' : 'text-zinc-500 hover:text-zinc-300'"
@click="libraryFilter = option.id" @click="libraryFilter = option.id"
> >
{{ option.label }} {{ option.label }}
</button> </button>
</div> </div>
</div>
<p v-if="!libraryEntries.length" class="mt-4 text-sm text-zinc-500">{{ libraryEmptyLabel }}</p> <p v-if="!libraryEntries.length" class="mt-4 text-sm text-zinc-500">{{ libraryEmptyLabel }}</p>
<div
v-if="selectedKeys.length"
class="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-amber-300/20 bg-amber-400/5 px-3 py-2 text-xs"
>
<span class="font-medium text-amber-100">{{ selectedKeys.length }} selected</span>
<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-amber-300"
:value="''"
@change="onMoveSelected($event)"
>
<option disabled value="">Move to…</option>
<option
v-for="folder in folders.filter(folder => folder.id !== browseFolderId)"
:key="folder.id"
:value="folder.id"
>
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
</option>
</select>
<button type="button" class="text-red-300 hover:text-red-200" @click="removeSelected">Delete</button>
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
</div>
<ul class="mt-4 space-y-3"> <ul class="mt-4 space-y-3">
<li <li
v-for="entry in libraryEntries" v-for="entry in libraryEntries"
:key="entry.key" :key="entry.key"
class="rounded-2xl border border-white/5 bg-zinc-950/40 p-3" class="rounded-2xl border p-3"
:class="selectedKeys.includes(entry.key) ? 'border-amber-300/30 bg-amber-400/5' : 'border-white/5 bg-zinc-950/40'"
> >
<template v-if="entry.kind === 'clip'"> <template v-if="entry.kind === 'clip'">
<div class="flex gap-3"> <div class="flex gap-3">
<label class="flex shrink-0 items-start pt-1" @click.stop>
<input
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-white/20 bg-zinc-950 accent-amber-400"
:checked="selectedKeys.includes(entry.key)"
@click.prevent="onSelectClick(entry.key, $event)"
>
</label>
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openClip(entry.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="entry.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="entry.group.head.hasThumbnail" :src="`/api/library/clips/${entry.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">
@@ -982,6 +1013,14 @@
</template> </template>
<template v-else-if="entry.kind === 'still' && entry.stillGroup"> <template v-else-if="entry.kind === 'still' && entry.stillGroup">
<div class="flex gap-3"> <div class="flex gap-3">
<label class="flex shrink-0 items-start pt-1" @click.stop>
<input
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-white/20 bg-zinc-950 accent-amber-400"
:checked="selectedKeys.includes(entry.key)"
@click.prevent="onSelectClick(entry.key, $event)"
>
</label>
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openStill(entry.stillGroup.head)"> <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" /> <span v-if="entry.stillGroup.head.hideInput" class="sensitive-glow block h-full w-full" />
<img <img
@@ -1293,6 +1332,7 @@ const EDIT_JOB_STORE = 'aigen-active-edit-job'
const videoBusy = ref(false) const videoBusy = ref(false)
const editBusy = ref(false) const editBusy = ref(false)
const studioMode = ref('video') const studioMode = ref('video')
const outputFocus = ref<'video' | 'edit'>('video')
const imageComfyOk = ref(false) const imageComfyOk = ref(false)
const imageComfyBusy = ref(false) const imageComfyBusy = ref(false)
const imageComfyConfigured = ref(false) const imageComfyConfigured = ref(false)
@@ -1377,6 +1417,8 @@ 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 libraryFilter = ref<'all' | 'images' | 'videos'>('all')
const selectedKeys = ref<string[]>([])
const lastSelectedKey = ref('')
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])
@@ -1409,56 +1451,75 @@ function formatElapsed(ms: number) {
const elapsedLabel = computed(() => formatElapsed(elapsedMs.value)) const elapsedLabel = computed(() => formatElapsed(elapsedMs.value))
const editElapsedLabel = computed(() => formatElapsed(editElapsedMs.value)) const editElapsedLabel = computed(() => formatElapsed(editElapsedMs.value))
const comfyBadge = computed(() => { const outputProgressKind = computed<'video' | 'edit'>(() => {
if (!comfyOk.value) { if (editBusy.value && !videoBusy.value) return 'edit'
if (videoBusy.value && !editBusy.value) return 'video'
return outputFocus.value
})
const outputPreviewKind = computed<'video' | 'edit'>(() => {
if (outputFocus.value === 'video') {
if (editBusy.value && !videoBusy.value && !videoUrl.value) return 'edit'
return 'video'
}
if (videoBusy.value && !editBusy.value && !editResultUrl.value) return 'video'
return 'edit'
})
const liveElapsedLabel = computed(() => (
outputProgressKind.value === 'edit' ? editElapsedLabel.value : elapsedLabel.value
))
const liveStatusMessage = computed(() => (
outputProgressKind.value === 'edit' ? editStatusMessage.value : statusMessage.value
))
const liveStatusBusy = computed(() => (
outputProgressKind.value === 'edit' ? editStatusBusy.value : statusBusy.value
))
const liveProgressPct = computed(() => (
outputProgressKind.value === 'edit'
? (editOverallProgress.value || editProgress.value)
: progress.value
))
const liveStep = computed(() => (
outputProgressKind.value === 'edit' ? editStep.value : step.value
))
const liveMaxStep = computed(() => (
outputProgressKind.value === 'edit'
? editMaxStep.value
: (maxStep.value || defaultVideoSteps(turbo.value, videoWorkflow.value))
))
const rigBusy = computed(() => (
comfyBusy.value || imageComfyBusy.value || videoBusy.value || editBusy.value
))
const rigBadge = computed(() => {
if (!comfyOk.value && !imageComfyOk.value) {
return { return {
label: 'ComfyUI offline', label: 'Offline',
className: 'border-red-400/30 text-red-300', className: 'border-red-400/30 text-red-300',
dot: 'bg-red-400' dot: 'bg-red-400',
title: 'Desktop GPU is unreachable'
} }
} }
if (comfyBusy.value) { if (rigBusy.value) {
return { return {
label: 'ComfyUI busy', label: 'Live',
className: 'border-amber-400/40 text-amber-200', className: 'border-amber-400/40 text-amber-200',
dot: 'bg-amber-400' dot: 'bg-amber-400 animate-pulse',
title: 'Desktop GPU is rendering'
} }
} }
return { return {
label: 'ComfyUI online', label: 'Ready',
className: 'border-emerald-400/30 text-emerald-300', className: 'border-emerald-400/30 text-emerald-300',
dot: 'bg-emerald-400' dot: 'bg-emerald-400',
title: 'Desktop GPU is free'
} }
}) })
const imageComfyBadge = computed(() => { const canClearPreview = computed(() => Boolean(
if (!imageComfyConfigured.value && !comfyOk.value) { videoUrl.value || editResultUrl.value || extendOpen.value || lockedSave.value || editLockedSave.value
return { ))
label: 'Edit unset',
className: 'border-white/10 text-zinc-500',
dot: 'bg-zinc-500'
}
}
if (comfyOk.value && !comfyBusy.value) {
return {
label: 'Beast · Klein ready',
className: 'border-emerald-400/30 text-emerald-300',
dot: 'bg-emerald-400'
}
}
if (comfyBusy.value) {
return {
label: 'Beast · busy',
className: 'border-amber-400/40 text-amber-200',
dot: 'bg-amber-400'
}
}
return {
label: 'Beast offline',
className: 'border-red-400/30 text-red-300',
dot: 'bg-red-400'
}
})
function snap32(value: number) { function snap32(value: number) {
return Math.max(32, Math.round(value / 32) * 32) return Math.max(32, Math.round(value / 32) * 32)
@@ -1523,7 +1584,7 @@ const editBlocked = computed(() => {
}) })
const editBlockReason = computed(() => { const editBlockReason = computed(() => {
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image edit is not configured. Set COMFY_HOST.' if (!imageComfyConfigured.value && !comfyOk.value) return 'Image edit is not configured. Set COMFY_HOST.'
if (!comfyOk.value && !imageComfyOk.value) return 'Beast is offline.' if (!comfyOk.value && !imageComfyOk.value) return 'Desktop GPU is offline.'
return '' return ''
}) })
const generateLabel = computed(() => { const generateLabel = computed(() => {
@@ -1903,6 +1964,19 @@ watch(folderId, () => {
applyThumbnailDefault() applyThumbnailDefault()
}) })
watch([browseFolderId, libraryFilter], () => {
selectedKeys.value = []
lastSelectedKey.value = ''
})
watch(editBusy, (busy) => {
if (busy) outputFocus.value = 'edit'
})
watch(videoBusy, (busy) => {
if (busy) outputFocus.value = 'video'
})
watch(hideInputPreview, (value) => { watch(hideInputPreview, (value) => {
try { try {
localStorage.setItem('aigen-hide-input-preview', String(value)) localStorage.setItem('aigen-hide-input-preview', String(value))
@@ -2303,6 +2377,7 @@ async function loadRerunImage(item: LibraryClip, parts: LibraryClip[]) {
async function rerun(item: LibraryClip, collection = false) { async function rerun(item: LibraryClip, collection = false) {
studioMode.value = 'video' studioMode.value = 'video'
outputFocus.value = 'video'
closeExtend() closeExtend()
const parts = familyPartsFor(item) const parts = familyPartsFor(item)
const target = collection ? parts[0] || item : item const target = collection ? parts[0] || item : item
@@ -2518,6 +2593,7 @@ async function generateExtension() {
function openClip(item: LibraryClip) { function openClip(item: LibraryClip) {
studioMode.value = 'video' studioMode.value = 'video'
outputFocus.value = 'video'
closeExtend() closeExtend()
watchingLibrary.value = true watchingLibrary.value = true
currentClipId.value = item.id currentClipId.value = item.id
@@ -2536,6 +2612,7 @@ function openClip(item: LibraryClip) {
function openStill(still: LibraryStill) { function openStill(still: LibraryStill) {
studioMode.value = 'edit' studioMode.value = 'edit'
outputFocus.value = 'edit'
browseFolderId.value = still.folderId browseFolderId.value = still.folderId
editResultUrl.value = `/api/library/stills/${still.id}` editResultUrl.value = `/api/library/stills/${still.id}`
concealEditOutput.value = still.hideInput === true concealEditOutput.value = still.hideInput === true
@@ -2594,10 +2671,7 @@ async function onMoveStill(still: LibraryStill, event: Event, family = false) {
body: { folderId: destId, family: family || undefined } body: { folderId: destId, family: family || undefined }
})) }))
const dest = folders.value.find(folder => folder.id === destId) const dest = folders.value.find(folder => folder.id === destId)
if (dest?.unlocked) browseFolderId.value = destId toast(`Moved ${family ? 'edit chain' : 'still'} to ${dest?.name || 'folder'}${dest?.unlocked ? '' : ' (locked)'}`)
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) { } catch (error: any) {
toast(error?.data?.statusMessage || 'Could not move image') toast(error?.data?.statusMessage || 'Could not move image')
} }
@@ -2647,6 +2721,133 @@ function stillFamilyPartsFor(still: LibraryStill) {
return chronologicalStills(group?.parts || [still]) return chronologicalStills(group?.parts || [still])
} }
function clearPreview() {
closeExtend()
videoUrl.value = ''
awaitingReveal.value = false
concealOutput.value = false
lockedSave.value = false
currentClipId.value = ''
watchingLibrary.value = false
editResultUrl.value = ''
editAwaitingReveal.value = false
concealEditOutput.value = false
editLockedSave.value = false
if (!videoBusy.value) {
statusMessage.value = 'Waiting for a still.'
progress.value = 0
}
if (!editBusy.value) {
editStatusMessage.value = 'Waiting for a still.'
editProgress.value = 0
}
}
function selectedLibraryEntries() {
const chosen = new Set(selectedKeys.value)
return libraryEntries.value.filter(entry => chosen.has(entry.key))
}
function clearSelection() {
selectedKeys.value = []
lastSelectedKey.value = ''
}
function selectAllLibrary() {
selectedKeys.value = libraryEntries.value.map(entry => entry.key)
lastSelectedKey.value = selectedKeys.value.at(-1) || ''
}
function onSelectClick(key: string, event: MouseEvent) {
const keys = libraryEntries.value.map(entry => entry.key)
if (event.shiftKey && lastSelectedKey.value) {
const start = keys.indexOf(lastSelectedKey.value)
const end = keys.indexOf(key)
if (start >= 0 && end >= 0) {
const [from, to] = start < end ? [start, end] : [end, start]
const range = keys.slice(from, to + 1)
selectedKeys.value = [...new Set([...selectedKeys.value, ...range])]
lastSelectedKey.value = key
return
}
}
if (selectedKeys.value.includes(key)) {
selectedKeys.value = selectedKeys.value.filter(item => item !== key)
} else {
selectedKeys.value = [...selectedKeys.value, key]
}
lastSelectedKey.value = key
}
async function onMoveSelected(event: Event) {
const select = event.target as HTMLSelectElement
const destId = select.value
select.value = ''
if (!destId) return
const items = selectedLibraryEntries()
if (!items.length) return
const dest = folders.value.find(folder => folder.id === destId)
const results = await Promise.allSettled(items.map(entry => {
if (entry.kind === 'clip') {
return $fetch(`/api/library/clips/${entry.group.head.id}`, {
method: 'PATCH',
body: { folderId: destId, family: true }
})
}
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, {
method: 'PATCH',
body: { folderId: destId, family: true }
})
}))
await loadLibrary()
const failed = results.filter(result => result.status === 'rejected').length
const moved = items.length - failed
clearSelection()
if (failed) toast(`${moved} moved to ${dest?.name || 'folder'}. ${failed} could not be moved.`)
else toast(`Moved ${moved} to ${dest?.name || 'folder'}${dest?.unlocked ? '' : ' (locked)'}`)
}
async function removeSelected() {
const items = selectedLibraryEntries()
if (!items.length) return
const label = items.length === 1 ? 'this item' : `these ${items.length} items`
if (!confirm(`Remove ${label} from this folder?`)) return
const clipIds = new Set<string>()
const stillIds = new Set<string>()
const results = await Promise.allSettled(items.map(entry => {
if (entry.kind === 'clip') {
for (const part of entry.group.parts) clipIds.add(part.id)
return $fetch(`/api/library/clips/${entry.group.head.id}`, {
method: 'DELETE',
query: { family: '1' }
})
}
for (const part of entry.stillGroup!.parts) stillIds.add(part.id)
return $fetch(`/api/library/stills/${entry.stillGroup!.head.id}`, {
method: 'DELETE',
query: { family: '1' }
})
}))
if ([...clipIds].some(id => videoUrl.value.includes(id) || currentClipId.value === id)) {
videoUrl.value = ''
awaitingReveal.value = false
concealOutput.value = false
lockedSave.value = false
currentClipId.value = ''
closeExtend()
}
if ([...stillIds].some(id => editResultUrl.value.includes(id))) {
editResultUrl.value = ''
editAwaitingReveal.value = false
concealEditOutput.value = false
editLockedSave.value = false
}
await loadLibrary()
const failed = results.filter(result => result.status === 'rejected').length
clearSelection()
if (failed) toast(`${items.length - failed} removed. ${failed} could not be deleted.`)
}
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 {
@@ -2680,10 +2881,7 @@ async function onMoveClip(item: LibraryClip, event: Event, family = false) {
body: { folderId: destId, family: family || undefined } body: { folderId: destId, family: family || undefined }
})) }))
const dest = folders.value.find(folder => folder.id === destId) const dest = folders.value.find(folder => folder.id === destId)
if (dest?.unlocked) browseFolderId.value = destId toast(`Moved ${family ? 'collection' : 'clip'} to ${dest?.name || 'folder'}${dest?.unlocked ? '' : ' (locked)'}`)
statusMessage.value = dest?.unlocked
? `Moved ${family ? 'collection' : 'clip'} to ${dest.name}`
: `Moved ${family ? 'collection' : 'clip'} to ${dest?.name || 'folder'} (locked)`
} catch (error: any) { } catch (error: any) {
toast(error?.data?.statusMessage || 'Could not move clip') toast(error?.data?.statusMessage || 'Could not move clip')
} }
@@ -3260,16 +3458,20 @@ function listen(kind: 'video' | 'edit', id: string, hidden: boolean, folderLocke
} }
async function cancelJob() { async function cancelJob() {
const kind = studioMode.value === 'edit' ? 'edit' : 'video' const kind = outputProgressKind.value === 'edit' && editBusy.value
? 'edit'
: videoBusy.value
? 'video'
: studioMode.value === 'edit' ? 'edit' : 'video'
const id = kind === 'edit' ? editJobId.value : videoJobId.value const id = kind === 'edit' ? editJobId.value : videoJobId.value
try { try {
await $fetch('/api/interrupt', { method: 'POST', body: { jobId: id, kind } }) await $fetch('/api/interrupt', { method: 'POST', body: { jobId: id, kind } })
if (kind === 'edit') editStatusMessage.value = 'Edit cancelled. Sidecar is idle — edit again.' if (kind === 'edit') editStatusMessage.value = 'Edit cancelled. GPU is idle — edit again.'
else statusMessage.value = 'Job cancelled. ComfyUI is idle — generate again.' else statusMessage.value = 'Job cancelled. GPU is idle — generate again.'
} catch (error: any) { } catch (error: any) {
toast(error?.statusMessage || error?.data?.statusMessage || 'Failed to interrupt ComfyUI') toast(error?.statusMessage || error?.data?.statusMessage || 'Failed to interrupt the job')
if (kind === 'edit') editStatusMessage.value = 'Cancelled locally. Edit again when a box is free.' if (kind === 'edit') editStatusMessage.value = 'Cancelled locally. Edit again when the GPU is free.'
else statusMessage.value = 'Cancelled locally. Generate again when ComfyUI is free.' else statusMessage.value = 'Cancelled locally. Generate again when the GPU is free.'
} }
if (kind === 'edit') { if (kind === 'edit') {
editSettledUi = true editSettledUi = true