|
|
|
@@ -11,15 +11,15 @@
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-3 text-sm">
|
|
|
|
|
<span
|
|
|
|
|
class="hidden sm:inline-flex items-center gap-2 rounded-full border px-3 py-1"
|
|
|
|
|
:class="comfyBadge.className"
|
|
|
|
|
class="items-center gap-2 rounded-full border px-3 py-1"
|
|
|
|
|
:class="[studioMode === 'video' ? 'hidden sm:inline-flex' : 'hidden xl:inline-flex opacity-60', comfyBadge.className]"
|
|
|
|
|
>
|
|
|
|
|
<span class="h-2 w-2 rounded-full" :class="comfyBadge.dot" />
|
|
|
|
|
{{ comfyBadge.label }}
|
|
|
|
|
</span>
|
|
|
|
|
<span
|
|
|
|
|
class="hidden lg:inline-flex items-center gap-2 rounded-full border px-3 py-1"
|
|
|
|
|
:class="imageComfyBadge.className"
|
|
|
|
|
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 sidecar'"
|
|
|
|
|
>
|
|
|
|
|
<span class="h-2 w-2 rounded-full" :class="imageComfyBadge.dot" />
|
|
|
|
@@ -137,7 +137,7 @@
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="text-xs text-red-300 hover:text-red-200"
|
|
|
|
|
:disabled="busy"
|
|
|
|
|
:disabled="videoBusy"
|
|
|
|
|
@click="removeQueuedExtension(item.id)"
|
|
|
|
|
>
|
|
|
|
|
Remove
|
|
|
|
@@ -154,7 +154,7 @@
|
|
|
|
|
<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="busy"
|
|
|
|
|
:disabled="videoBusy"
|
|
|
|
|
@click="queueExtension"
|
|
|
|
|
>
|
|
|
|
|
+ Queue Extension
|
|
|
|
@@ -272,11 +272,18 @@
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="block w-full text-left"
|
|
|
|
|
class="relative block w-full text-left"
|
|
|
|
|
@click="openPicker(slot - 1)"
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
v-if="identityPreviews[slot - 1] && hideInputPreview"
|
|
|
|
|
class="relative flex h-24 items-center justify-center px-2 text-center text-[11px] text-zinc-300"
|
|
|
|
|
>
|
|
|
|
|
<span class="sensitive-glow absolute inset-1 rounded-lg" />
|
|
|
|
|
<span class="relative">Ref {{ slot }} · hidden</span>
|
|
|
|
|
</span>
|
|
|
|
|
<img
|
|
|
|
|
v-if="identityPreviews[slot - 1]"
|
|
|
|
|
v-else-if="identityPreviews[slot - 1]"
|
|
|
|
|
:src="identityPreviews[slot - 1]"
|
|
|
|
|
alt=""
|
|
|
|
|
class="h-24 w-full object-cover"
|
|
|
|
@@ -403,6 +410,7 @@
|
|
|
|
|
<div class="mt-4 space-y-4">
|
|
|
|
|
<p class="text-xs text-zinc-500">
|
|
|
|
|
Sidecar: <span class="text-zinc-200">{{ imageComfyHost || 'not configured' }}</span>
|
|
|
|
|
· {{ imageComfyBadge.label }}
|
|
|
|
|
· Qwen Image Edit Lightning (4-step LoRA)
|
|
|
|
|
</p>
|
|
|
|
|
<label class="block text-sm">
|
|
|
|
@@ -435,13 +443,13 @@
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="flex-1 rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
|
|
:disabled="busy || !file || !prompt.trim() || !folderId || (studioMode !== 'edit' && !queueReady)"
|
|
|
|
|
:disabled="studioMode === 'edit' ? editBlocked : videoGenerateDisabled"
|
|
|
|
|
@click="studioMode === 'edit' ? editImage() : generate()"
|
|
|
|
|
>
|
|
|
|
|
{{ studioMode === 'edit' ? (busy ? 'Editing…' : 'Edit image') : generateLabel }}
|
|
|
|
|
{{ studioMode === 'edit' ? (editBusy ? 'Editing…' : 'Edit image') : generateLabel }}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
v-if="busy"
|
|
|
|
|
v-if="studioMode === 'edit' ? editBusy : videoBusy"
|
|
|
|
|
type="button"
|
|
|
|
|
class="rounded-2xl border border-red-400/40 px-4 py-3 text-red-300"
|
|
|
|
|
@click="cancelJob"
|
|
|
|
@@ -449,6 +457,7 @@
|
|
|
|
|
Cancel / Interrupt
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<p v-if="studioMode === 'edit' && editBlockReason" class="text-xs text-zinc-400">{{ editBlockReason }}</p>
|
|
|
|
|
|
|
|
|
|
<div v-if="drafts.length" class="rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
|
|
|
|
|
<h3 class="text-sm font-semibold text-amber-100">Held jobs</h3>
|
|
|
|
@@ -473,12 +482,20 @@
|
|
|
|
|
<div ref="outputPanel" class="glass rounded-3xl p-5 sm:p-6">
|
|
|
|
|
<div class="flex items-center justify-between gap-3">
|
|
|
|
|
<h2 class="font-display text-2xl font-bold">Output</h2>
|
|
|
|
|
<p class="text-xs text-zinc-500">
|
|
|
|
|
<p v-if="studioMode === 'edit'" class="text-xs text-zinc-500">
|
|
|
|
|
{{ editElapsedLabel }}
|
|
|
|
|
· step {{ editStep }}/{{ editMaxStep }}
|
|
|
|
|
</p>
|
|
|
|
|
<p v-else class="text-xs text-zinc-500">
|
|
|
|
|
{{ elapsedLabel }}
|
|
|
|
|
<template v-if="chainTotal > 1"> · chain {{ chainStep }}/{{ chainTotal }}</template>
|
|
|
|
|
· step {{ step }}/{{ maxStep || (turbo ? 8 : 20) }}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<p v-if="studioMode === 'edit' && videoBusy" class="mt-2 text-xs text-zinc-500">Video generation continues in the background.</p>
|
|
|
|
|
<p v-else-if="studioMode === 'video' && editBusy" class="mt-2 text-xs text-zinc-500">Image edit continues in the background.</p>
|
|
|
|
|
|
|
|
|
|
<div v-show="studioMode === 'video'">
|
|
|
|
|
<div class="mt-4">
|
|
|
|
|
<div class="mb-2 flex items-center justify-between text-sm">
|
|
|
|
|
<span :class="statusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ statusMessage }}</span>
|
|
|
|
@@ -488,7 +505,7 @@
|
|
|
|
|
Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }}
|
|
|
|
|
<template v-if="overallProgress"> · {{ overallProgress }}% overall</template>
|
|
|
|
|
</p>
|
|
|
|
|
<ol v-if="busy && activeChainPlan.length > 1" class="mb-3 space-y-1">
|
|
|
|
|
<ol v-if="videoBusy && activeChainPlan.length > 1" class="mb-3 space-y-1">
|
|
|
|
|
<li
|
|
|
|
|
v-for="(part, index) in activeChainPlan"
|
|
|
|
|
:key="index"
|
|
|
|
@@ -509,16 +526,9 @@
|
|
|
|
|
class="relative mt-5 overflow-hidden rounded-2xl bg-black/50"
|
|
|
|
|
:class="{ 'sensitive-glow': awaitingReveal || lockedSave }"
|
|
|
|
|
:style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}` }"
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
v-if="editResultUrl && !lockedSave"
|
|
|
|
|
:src="editResultUrl"
|
|
|
|
|
alt="Edited still"
|
|
|
|
|
class="h-full w-full bg-black object-contain"
|
|
|
|
|
:class="{ 'opacity-0': awaitingReveal }"
|
|
|
|
|
>
|
|
|
|
|
<video
|
|
|
|
|
v-else-if="videoUrl && !lockedSave"
|
|
|
|
|
v-if="videoUrl && !lockedSave"
|
|
|
|
|
ref="player"
|
|
|
|
|
:src="videoUrl"
|
|
|
|
|
class="h-full w-full bg-black"
|
|
|
|
@@ -546,33 +556,17 @@
|
|
|
|
|
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this clip</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
v-else-if="!videoUrl && !editResultUrl"
|
|
|
|
|
v-else-if="!videoUrl"
|
|
|
|
|
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
|
|
|
|
|
>
|
|
|
|
|
{{ studioMode === 'edit' ? 'Edited still appears here' : 'Generated MP4 appears here' }}
|
|
|
|
|
Generated MP4 appears here
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="editResultUrl && !lockedSave && !awaitingReveal" class="mt-4 flex flex-wrap gap-3">
|
|
|
|
|
<a
|
|
|
|
|
:href="editResultUrl"
|
|
|
|
|
:download="`${outputDownloadName || 'aigen-edit'}.png`"
|
|
|
|
|
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
|
|
|
|
|
>
|
|
|
|
|
Download still
|
|
|
|
|
</a>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
|
|
|
|
|
@click="useEditAsInput"
|
|
|
|
|
>
|
|
|
|
|
Use as input still
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="videoUrl && !lockedSave" class="mt-4 flex flex-wrap gap-3">
|
|
|
|
|
<a
|
|
|
|
|
v-if="!awaitingReveal"
|
|
|
|
|
:href="videoUrl"
|
|
|
|
|
:download="`${outputDownloadName}.mp4`"
|
|
|
|
|
:download="`${videoDownloadName}.mp4`"
|
|
|
|
|
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
|
|
|
|
|
>
|
|
|
|
|
Download MP4
|
|
|
|
@@ -617,7 +611,7 @@
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="w-full rounded-2xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
|
|
:disabled="!extendPrompt.trim() || lastFrameLoading"
|
|
|
|
|
:disabled="!extendPrompt.trim() || lastFrameLoading || videoBusy"
|
|
|
|
|
@click="generateExtension"
|
|
|
|
|
>
|
|
|
|
|
Generate Extension
|
|
|
|
@@ -625,6 +619,70 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-show="studioMode === '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>
|
|
|
|
|
<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>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
class="relative mt-5 overflow-hidden rounded-2xl bg-black/50"
|
|
|
|
|
:class="{ 'sensitive-glow': editAwaitingReveal || editLockedSave }"
|
|
|
|
|
style="min-height: 12rem"
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
v-if="editResultUrl && !editLockedSave"
|
|
|
|
|
:src="editResultUrl"
|
|
|
|
|
alt="Edited still"
|
|
|
|
|
class="max-h-96 w-full bg-black object-contain"
|
|
|
|
|
:class="{ 'opacity-0': editAwaitingReveal }"
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
v-if="editAwaitingReveal && !editLockedSave"
|
|
|
|
|
type="button"
|
|
|
|
|
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-amber-100"
|
|
|
|
|
@click="editAwaitingReveal = false"
|
|
|
|
|
>
|
|
|
|
|
<span class="font-display text-lg font-bold">Ready</span>
|
|
|
|
|
<span class="text-sm text-amber-100/80">Click to reveal · preview hidden</span>
|
|
|
|
|
</button>
|
|
|
|
|
<div
|
|
|
|
|
v-else-if="editLockedSave"
|
|
|
|
|
class="absolute inset-0 flex flex-col items-center justify-center gap-2 px-6 text-center text-amber-100"
|
|
|
|
|
>
|
|
|
|
|
<span class="font-display text-lg font-bold">Saved to a locked folder</span>
|
|
|
|
|
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this still</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
v-else-if="!editResultUrl"
|
|
|
|
|
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
|
|
|
|
|
>
|
|
|
|
|
Edited still appears here
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="editResultUrl && !editLockedSave && !editAwaitingReveal" class="mt-4 flex flex-wrap gap-3">
|
|
|
|
|
<a
|
|
|
|
|
:href="editResultUrl"
|
|
|
|
|
:download="`${editDownloadName || 'aigen-edit'}.png`"
|
|
|
|
|
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
|
|
|
|
|
>
|
|
|
|
|
Download still
|
|
|
|
|
</a>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
|
|
|
|
|
@click="useEditAsInput"
|
|
|
|
|
>
|
|
|
|
|
Use as input still
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
@@ -928,7 +986,8 @@ const prompt = ref('')
|
|
|
|
|
const shotScriptMode = ref(false)
|
|
|
|
|
const extensionQueue = ref<QueuedExtension[]>([])
|
|
|
|
|
const clipName = ref('')
|
|
|
|
|
const outputDownloadName = ref('aigen')
|
|
|
|
|
const videoDownloadName = ref('aigen')
|
|
|
|
|
const editDownloadName = ref('aigen-edit')
|
|
|
|
|
const aspect = ref<AspectId>('auto')
|
|
|
|
|
const imageWidth = ref(0)
|
|
|
|
|
const imageHeight = ref(0)
|
|
|
|
@@ -950,7 +1009,9 @@ const hideThumbnail = ref(false)
|
|
|
|
|
const hideInputPreview = ref(false)
|
|
|
|
|
const autoplayEnabled = ref(false)
|
|
|
|
|
const JOB_STORE = 'aigen-active-job'
|
|
|
|
|
const busy = ref(false)
|
|
|
|
|
const EDIT_JOB_STORE = 'aigen-active-edit-job'
|
|
|
|
|
const videoBusy = ref(false)
|
|
|
|
|
const editBusy = ref(false)
|
|
|
|
|
const studioMode = ref('video')
|
|
|
|
|
const imageComfyOk = ref(false)
|
|
|
|
|
const imageComfyBusy = ref(false)
|
|
|
|
@@ -971,11 +1032,20 @@ const expandedFamilies = ref<Record<string, boolean>>({})
|
|
|
|
|
const elapsedMs = ref(0)
|
|
|
|
|
const videoUrl = ref('')
|
|
|
|
|
const editResultUrl = ref('')
|
|
|
|
|
const editProgress = ref(0)
|
|
|
|
|
const editStatusMessage = ref('Waiting for a still.')
|
|
|
|
|
const editStatusBusy = ref(false)
|
|
|
|
|
const editStep = ref(0)
|
|
|
|
|
const editMaxStep = ref(4)
|
|
|
|
|
const editElapsedMs = ref(0)
|
|
|
|
|
const editAwaitingReveal = ref(false)
|
|
|
|
|
const editLockedSave = ref(false)
|
|
|
|
|
const editSteps = ref(4)
|
|
|
|
|
const editCfg = ref(1)
|
|
|
|
|
const editDenoise = ref(0.75)
|
|
|
|
|
const editNegative = ref('blurry, low quality, deformed, bad anatomy, watermark, text, extra fingers')
|
|
|
|
|
const jobId = ref('')
|
|
|
|
|
const videoJobId = ref('')
|
|
|
|
|
const editJobId = ref('')
|
|
|
|
|
const currentClipId = ref('')
|
|
|
|
|
const extendOpen = ref(false)
|
|
|
|
|
const extendPrompt = ref('')
|
|
|
|
@@ -1018,20 +1088,28 @@ const pickerHeading = computed(() => {
|
|
|
|
|
if (pickerSlot.value === 'main') return 'Choose an image'
|
|
|
|
|
return `Choose identity ref ${Number(pickerSlot.value) + 1}`
|
|
|
|
|
})
|
|
|
|
|
let timer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
let source: EventSource | null = null
|
|
|
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
let videoTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
let editTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
let videoSource: EventSource | null = null
|
|
|
|
|
let editSource: EventSource | null = null
|
|
|
|
|
let videoPollTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
let editPollTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
let healthTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
let settledUi = false
|
|
|
|
|
let listenGen = 0
|
|
|
|
|
let videoSettledUi = false
|
|
|
|
|
let editSettledUi = false
|
|
|
|
|
let videoListenGen = 0
|
|
|
|
|
let editListenGen = 0
|
|
|
|
|
let activeDraftId = ''
|
|
|
|
|
|
|
|
|
|
const elapsedLabel = computed(() => {
|
|
|
|
|
const seconds = Math.floor(elapsedMs.value / 1000)
|
|
|
|
|
function formatElapsed(ms: number) {
|
|
|
|
|
const seconds = Math.floor(ms / 1000)
|
|
|
|
|
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
|
|
|
|
|
const ss = String(seconds % 60).padStart(2, '0')
|
|
|
|
|
return `${mm}:${ss}`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const elapsedLabel = computed(() => formatElapsed(elapsedMs.value))
|
|
|
|
|
const editElapsedLabel = computed(() => formatElapsed(editElapsedMs.value))
|
|
|
|
|
|
|
|
|
|
const comfyBadge = computed(() => {
|
|
|
|
|
if (!comfyOk.value) {
|
|
|
|
@@ -1135,9 +1213,25 @@ const queueReady = computed(() => {
|
|
|
|
|
if (shotScriptMode.value) return parsedShots.value.length > 0 && parsedShots.value.every(shot => shot.prompt.trim())
|
|
|
|
|
return extensionQueue.value.every(item => item.prompt.trim())
|
|
|
|
|
})
|
|
|
|
|
const videoGenerateDisabled = computed(() => {
|
|
|
|
|
return videoBusy.value || !file.value || !prompt.value.trim() || !folderId.value || !queueReady.value
|
|
|
|
|
})
|
|
|
|
|
const editBlocked = computed(() => {
|
|
|
|
|
if (editBusy.value) return true
|
|
|
|
|
if (!file.value || !prompt.value.trim() || !folderId.value) return true
|
|
|
|
|
if (!imageComfyConfigured.value || !imageComfyOk.value) return true
|
|
|
|
|
if (imageComfyBusy.value) return true
|
|
|
|
|
return false
|
|
|
|
|
})
|
|
|
|
|
const editBlockReason = computed(() => {
|
|
|
|
|
if (!imageComfyConfigured.value) return 'Image edit sidecar is not configured.'
|
|
|
|
|
if (!imageComfyOk.value) return 'Edit Comfy is offline.'
|
|
|
|
|
if (imageComfyBusy.value && !editBusy.value) return 'Sidecar is busy with another prompt.'
|
|
|
|
|
return ''
|
|
|
|
|
})
|
|
|
|
|
const generateLabel = computed(() => {
|
|
|
|
|
const n = shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length
|
|
|
|
|
if (busy.value) return 'Generating…'
|
|
|
|
|
if (videoBusy.value) return 'Generating…'
|
|
|
|
|
if (!n) return 'Generate Video'
|
|
|
|
|
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
|
|
|
|
|
})
|
|
|
|
@@ -1221,7 +1315,7 @@ function chainPartDone(index: number) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function chainPartCurrent(index: number) {
|
|
|
|
|
return busy.value && index === completedChainStep.value
|
|
|
|
|
return videoBusy.value && index === completedChainStep.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function chainPartClass(index: number) {
|
|
|
|
@@ -1509,30 +1603,35 @@ function onDrop(event: DragEvent) {
|
|
|
|
|
void persistDroppedStill(next)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startTimer(offset = 0) {
|
|
|
|
|
elapsedMs.value = offset
|
|
|
|
|
stopTimer()
|
|
|
|
|
function startTimer(kind: 'video' | 'edit', offset = 0) {
|
|
|
|
|
const elapsed = kind === 'edit' ? editElapsedMs : elapsedMs
|
|
|
|
|
elapsed.value = offset
|
|
|
|
|
stopTimer(kind)
|
|
|
|
|
const started = Date.now() - offset
|
|
|
|
|
timer = setInterval(() => {
|
|
|
|
|
elapsedMs.value = Date.now() - started
|
|
|
|
|
const handle = setInterval(() => {
|
|
|
|
|
elapsed.value = Date.now() - started
|
|
|
|
|
}, 250)
|
|
|
|
|
if (kind === 'edit') editTimer = handle
|
|
|
|
|
else videoTimer = handle
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function persistActiveJob(id: string, hidden: boolean, folderLocked: boolean) {
|
|
|
|
|
function persistActiveJob(kind: 'video' | 'edit', id: string, hidden: boolean, folderLocked: boolean) {
|
|
|
|
|
const key = kind === 'edit' ? EDIT_JOB_STORE : JOB_STORE
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(JOB_STORE, JSON.stringify({ jobId: id, hideThumbnail: hidden, folderLocked, savedAt: Date.now() }))
|
|
|
|
|
localStorage.setItem(key, JSON.stringify({ jobId: id, hideThumbnail: hidden, folderLocked, savedAt: Date.now() }))
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearActiveJob() {
|
|
|
|
|
function clearActiveJob(kind?: 'video' | 'edit') {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.removeItem(JOB_STORE)
|
|
|
|
|
if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE)
|
|
|
|
|
if (!kind || kind === 'edit') localStorage.removeItem(EDIT_JOB_STORE)
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readStoredJob() {
|
|
|
|
|
function readStoredJob(key = JOB_STORE) {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(JOB_STORE)
|
|
|
|
|
const raw = localStorage.getItem(key)
|
|
|
|
|
if (!raw) return null
|
|
|
|
|
const parsed = JSON.parse(raw) as { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
|
|
|
|
|
return parsed?.jobId ? parsed : null
|
|
|
|
@@ -1541,31 +1640,68 @@ function readStoredJob() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resumeActiveJob() {
|
|
|
|
|
const stored = readStoredJob()
|
|
|
|
|
const active = await $fetch<{ jobId?: string | null; hideThumbnail?: boolean; folderLocked?: boolean; status?: string; elapsedMs?: number } | null>('/api/generate/active').catch(() => null)
|
|
|
|
|
const id = stored?.jobId || active?.jobId
|
|
|
|
|
if (!id) return
|
|
|
|
|
const hidden = Boolean(stored?.hideThumbnail || active?.hideThumbnail)
|
|
|
|
|
const locked = Boolean(stored?.folderLocked || active?.folderLocked)
|
|
|
|
|
async function resumeTrack(kind: 'video' | 'edit', id: string, hidden: boolean, locked: boolean) {
|
|
|
|
|
try {
|
|
|
|
|
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
|
|
|
|
|
jobId.value = id
|
|
|
|
|
if (kind === 'edit') {
|
|
|
|
|
editJobId.value = id
|
|
|
|
|
editSettledUi = false
|
|
|
|
|
} else {
|
|
|
|
|
videoJobId.value = id
|
|
|
|
|
concealOutput.value = hidden
|
|
|
|
|
settledUi = false
|
|
|
|
|
applyJobEvent(snap, hidden, locked, id)
|
|
|
|
|
videoSettledUi = false
|
|
|
|
|
}
|
|
|
|
|
applyJobEvent(kind, snap, hidden, locked, id)
|
|
|
|
|
const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running'
|
|
|
|
|
if (running && !settledUi) {
|
|
|
|
|
busy.value = true
|
|
|
|
|
const settled = kind === 'edit' ? editSettledUi : videoSettledUi
|
|
|
|
|
if (running && !settled) {
|
|
|
|
|
if (kind === 'edit') {
|
|
|
|
|
editBusy.value = true
|
|
|
|
|
editStatusMessage.value = snap.message || 'Reconnecting to sidecar ComfyUI...'
|
|
|
|
|
} else {
|
|
|
|
|
videoBusy.value = true
|
|
|
|
|
statusMessage.value = snap.message || 'Reconnecting to ComfyUI...'
|
|
|
|
|
startTimer(Number(snap.elapsedMs || 0))
|
|
|
|
|
persistActiveJob(id, hidden, locked)
|
|
|
|
|
listen(id, hidden, locked)
|
|
|
|
|
} else if (settledUi) {
|
|
|
|
|
clearActiveJob()
|
|
|
|
|
}
|
|
|
|
|
startTimer(kind, Number(snap.elapsedMs || 0))
|
|
|
|
|
persistActiveJob(kind, id, hidden, locked)
|
|
|
|
|
listen(kind, id, hidden, locked)
|
|
|
|
|
} else if (settled) {
|
|
|
|
|
clearActiveJob(kind)
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
clearActiveJob()
|
|
|
|
|
clearActiveJob(kind)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resumeActiveJob() {
|
|
|
|
|
const storedVideo = readStoredJob(JOB_STORE)
|
|
|
|
|
const storedEdit = readStoredJob(EDIT_JOB_STORE)
|
|
|
|
|
const active = await $fetch<{
|
|
|
|
|
jobId?: string | null
|
|
|
|
|
video?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
|
|
|
|
|
edit?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
|
|
|
|
|
hideThumbnail?: boolean
|
|
|
|
|
folderLocked?: boolean
|
|
|
|
|
} | null>('/api/generate/active').catch(() => null)
|
|
|
|
|
|
|
|
|
|
const videoId = storedVideo?.jobId || active?.video?.jobId || (!active?.edit?.jobId ? active?.jobId : null) || ''
|
|
|
|
|
const editId = storedEdit?.jobId || active?.edit?.jobId || ''
|
|
|
|
|
|
|
|
|
|
if (videoId) {
|
|
|
|
|
await resumeTrack(
|
|
|
|
|
'video',
|
|
|
|
|
videoId,
|
|
|
|
|
Boolean(storedVideo?.hideThumbnail || active?.video?.hideThumbnail || active?.hideThumbnail),
|
|
|
|
|
Boolean(storedVideo?.folderLocked || active?.video?.folderLocked || active?.folderLocked)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if (editId) {
|
|
|
|
|
await resumeTrack(
|
|
|
|
|
'edit',
|
|
|
|
|
editId,
|
|
|
|
|
Boolean(storedEdit?.hideThumbnail || active?.edit?.hideThumbnail),
|
|
|
|
|
Boolean(storedEdit?.folderLocked || active?.edit?.folderLocked)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1586,9 +1722,15 @@ function concealAndPause() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopTimer() {
|
|
|
|
|
if (timer) clearInterval(timer)
|
|
|
|
|
timer = null
|
|
|
|
|
function stopTimer(kind?: 'video' | 'edit') {
|
|
|
|
|
if (!kind || kind === 'video') {
|
|
|
|
|
if (videoTimer) clearInterval(videoTimer)
|
|
|
|
|
videoTimer = null
|
|
|
|
|
}
|
|
|
|
|
if (!kind || kind === 'edit') {
|
|
|
|
|
if (editTimer) clearInterval(editTimer)
|
|
|
|
|
editTimer = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clipFileName(name: string) {
|
|
|
|
@@ -1741,13 +1883,12 @@ async function openExtend(item?: LibraryClip) {
|
|
|
|
|
async function generateExtension() {
|
|
|
|
|
const id = clipIdFromPlayer()
|
|
|
|
|
if (!id || !extendPrompt.value.trim()) return
|
|
|
|
|
const switching = busy.value
|
|
|
|
|
const switching = videoBusy.value
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
busy.value = true
|
|
|
|
|
videoBusy.value = true
|
|
|
|
|
watchingLibrary.value = false
|
|
|
|
|
listenGen += 1
|
|
|
|
|
stopListen()
|
|
|
|
|
settledUi = false
|
|
|
|
|
stopListen('video')
|
|
|
|
|
videoSettledUi = false
|
|
|
|
|
progress.value = 1
|
|
|
|
|
awaitingReveal.value = false
|
|
|
|
|
concealOutput.value = hideThumbnail.value || currentClip.value?.hideThumbnail === true
|
|
|
|
@@ -1759,7 +1900,7 @@ async function generateExtension() {
|
|
|
|
|
completedChainStep.value = 0
|
|
|
|
|
activeChainPlan.value = []
|
|
|
|
|
statusMessage.value = 'Extracting last frame & stitching extension...'
|
|
|
|
|
startTimer()
|
|
|
|
|
startTimer('video')
|
|
|
|
|
try {
|
|
|
|
|
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/extend', {
|
|
|
|
|
method: 'POST',
|
|
|
|
@@ -1771,17 +1912,17 @@ async function generateExtension() {
|
|
|
|
|
})
|
|
|
|
|
closeExtend()
|
|
|
|
|
if (switching) toast('Switched to this extension. The other job will keep saving to the library.')
|
|
|
|
|
jobId.value = started.jobId
|
|
|
|
|
videoJobId.value = started.jobId
|
|
|
|
|
maxStep.value = started.steps
|
|
|
|
|
settledUi = false
|
|
|
|
|
persistActiveJob(started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
|
|
|
|
|
listen(started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
|
|
|
|
|
videoSettledUi = false
|
|
|
|
|
persistActiveJob('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
|
|
|
|
|
listen('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
|
|
|
|
|
void pollComfyHealth()
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
busy.value = false
|
|
|
|
|
videoBusy.value = false
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
stopTimer()
|
|
|
|
|
clearActiveJob()
|
|
|
|
|
stopTimer('video')
|
|
|
|
|
clearActiveJob('video')
|
|
|
|
|
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start extension'
|
|
|
|
|
statusMessage.value = message
|
|
|
|
|
toast(message)
|
|
|
|
@@ -1798,8 +1939,8 @@ function openClip(item: LibraryClip) {
|
|
|
|
|
awaitingReveal.value = item.hideThumbnail
|
|
|
|
|
playerSize.value = { width: item.width || 1344, height: item.height || 768 }
|
|
|
|
|
browseFolderId.value = item.folderId
|
|
|
|
|
outputDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen')
|
|
|
|
|
if (!busy.value) {
|
|
|
|
|
videoDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen')
|
|
|
|
|
if (!videoBusy.value) {
|
|
|
|
|
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
|
|
|
|
|
progress.value = 100
|
|
|
|
|
}
|
|
|
|
@@ -1875,7 +2016,7 @@ async function saveRename(item: LibraryClip) {
|
|
|
|
|
}))
|
|
|
|
|
cancelRename()
|
|
|
|
|
if (videoUrl.value.includes(item.id)) {
|
|
|
|
|
outputDownloadName.value = clipFileName(name)
|
|
|
|
|
videoDownloadName.value = clipFileName(name)
|
|
|
|
|
}
|
|
|
|
|
statusMessage.value = `Renamed to ${name}`
|
|
|
|
|
} catch (error: any) {
|
|
|
|
@@ -1912,23 +2053,17 @@ async function editImage() {
|
|
|
|
|
toast('Choose a library folder before editing.')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
busy.value = true
|
|
|
|
|
listenGen += 1
|
|
|
|
|
stopListen()
|
|
|
|
|
settledUi = false
|
|
|
|
|
progress.value = 2
|
|
|
|
|
videoUrl.value = ''
|
|
|
|
|
editStatusBusy.value = false
|
|
|
|
|
editBusy.value = true
|
|
|
|
|
stopListen('edit')
|
|
|
|
|
editSettledUi = false
|
|
|
|
|
editProgress.value = 2
|
|
|
|
|
editResultUrl.value = ''
|
|
|
|
|
currentClipId.value = ''
|
|
|
|
|
closeExtend()
|
|
|
|
|
awaitingReveal.value = false
|
|
|
|
|
concealOutput.value = hideInputPreview.value
|
|
|
|
|
lockedSave.value = false
|
|
|
|
|
watchingLibrary.value = false
|
|
|
|
|
statusMessage.value = 'Checking sidecar ComfyUI...'
|
|
|
|
|
maxStep.value = editSteps.value
|
|
|
|
|
startTimer()
|
|
|
|
|
editAwaitingReveal.value = false
|
|
|
|
|
editLockedSave.value = false
|
|
|
|
|
editStatusMessage.value = 'Checking sidecar ComfyUI...'
|
|
|
|
|
editMaxStep.value = editSteps.value
|
|
|
|
|
startTimer('edit')
|
|
|
|
|
try {
|
|
|
|
|
const body = new FormData()
|
|
|
|
|
body.append('image', file.value)
|
|
|
|
@@ -1941,23 +2076,24 @@ async function editImage() {
|
|
|
|
|
body.append('folderId', folderId.value)
|
|
|
|
|
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
|
|
|
|
|
body.append('name', clipName.value.trim())
|
|
|
|
|
outputDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit')
|
|
|
|
|
editDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit')
|
|
|
|
|
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/edit', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body
|
|
|
|
|
})
|
|
|
|
|
jobId.value = started.jobId
|
|
|
|
|
maxStep.value = started.steps
|
|
|
|
|
persistActiveJob(started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
|
|
|
|
|
listen(started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
|
|
|
|
|
editJobId.value = started.jobId
|
|
|
|
|
editMaxStep.value = started.steps
|
|
|
|
|
persistActiveJob('edit', started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
|
|
|
|
|
listen('edit', started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
|
|
|
|
|
void pollComfyHealth()
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
settledUi = true
|
|
|
|
|
editSettledUi = true
|
|
|
|
|
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image edit failed'
|
|
|
|
|
statusMessage.value = message
|
|
|
|
|
editStatusMessage.value = message
|
|
|
|
|
toast(message)
|
|
|
|
|
busy.value = false
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
stopTimer()
|
|
|
|
|
editBusy.value = false
|
|
|
|
|
editStatusBusy.value = false
|
|
|
|
|
stopTimer('edit')
|
|
|
|
|
void pollComfyHealth()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -1966,7 +2102,7 @@ async function useEditAsInput() {
|
|
|
|
|
if (!editResultUrl.value) return
|
|
|
|
|
try {
|
|
|
|
|
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
|
|
|
|
|
readFile(new File([blob], `${outputDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }))
|
|
|
|
|
readFile(new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }))
|
|
|
|
|
toast('Edited still is now the input')
|
|
|
|
|
} catch {
|
|
|
|
|
toast('Could not load that still as input')
|
|
|
|
@@ -1994,13 +2130,11 @@ async function generate() {
|
|
|
|
|
? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value }))
|
|
|
|
|
: extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration }))
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
busy.value = true
|
|
|
|
|
listenGen += 1
|
|
|
|
|
stopListen()
|
|
|
|
|
settledUi = false
|
|
|
|
|
videoBusy.value = true
|
|
|
|
|
stopListen('video')
|
|
|
|
|
videoSettledUi = false
|
|
|
|
|
progress.value = 1
|
|
|
|
|
videoUrl.value = ''
|
|
|
|
|
editResultUrl.value = ''
|
|
|
|
|
currentClipId.value = ''
|
|
|
|
|
closeExtend()
|
|
|
|
|
awaitingReveal.value = false
|
|
|
|
@@ -2015,7 +2149,7 @@ async function generate() {
|
|
|
|
|
overallProgress.value = 0
|
|
|
|
|
completedChainStep.value = 0
|
|
|
|
|
activeChainPlan.value = plannedChain.value.map(part => ({ ...part }))
|
|
|
|
|
startTimer()
|
|
|
|
|
startTimer('video')
|
|
|
|
|
try {
|
|
|
|
|
const body = new FormData()
|
|
|
|
|
body.append('image', file.value)
|
|
|
|
@@ -2047,42 +2181,100 @@ async function generate() {
|
|
|
|
|
if (queued.length) {
|
|
|
|
|
body.append('extensions', JSON.stringify(queued))
|
|
|
|
|
}
|
|
|
|
|
outputDownloadName.value = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
|
|
|
|
|
videoDownloadName.value = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
|
|
|
|
|
const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/generate', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body
|
|
|
|
|
})
|
|
|
|
|
jobId.value = started.jobId
|
|
|
|
|
videoJobId.value = started.jobId
|
|
|
|
|
maxStep.value = started.steps
|
|
|
|
|
playerSize.value = { width: started.width, height: started.height }
|
|
|
|
|
settledUi = false
|
|
|
|
|
persistActiveJob(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
|
|
|
|
listen(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
|
|
|
|
videoSettledUi = false
|
|
|
|
|
persistActiveJob('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
|
|
|
|
listen('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
|
|
|
|
void pollComfyHealth()
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
busy.value = false
|
|
|
|
|
videoBusy.value = false
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
stopTimer()
|
|
|
|
|
clearActiveJob()
|
|
|
|
|
stopTimer('video')
|
|
|
|
|
clearActiveJob('video')
|
|
|
|
|
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation'
|
|
|
|
|
statusMessage.value = message
|
|
|
|
|
toast(message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopListen() {
|
|
|
|
|
source?.close()
|
|
|
|
|
source = null
|
|
|
|
|
if (pollTimer) {
|
|
|
|
|
clearInterval(pollTimer)
|
|
|
|
|
pollTimer = null
|
|
|
|
|
function stopListen(kind?: 'video' | 'edit') {
|
|
|
|
|
if (!kind || kind === 'video') {
|
|
|
|
|
videoSource?.close()
|
|
|
|
|
videoSource = null
|
|
|
|
|
if (videoPollTimer) {
|
|
|
|
|
clearInterval(videoPollTimer)
|
|
|
|
|
videoPollTimer = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!kind || kind === 'edit') {
|
|
|
|
|
editSource?.close()
|
|
|
|
|
editSource = null
|
|
|
|
|
if (editPollTimer) {
|
|
|
|
|
clearInterval(editPollTimer)
|
|
|
|
|
editPollTimer = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean, expectedJobId: string) {
|
|
|
|
|
if (expectedJobId && jobId.value && expectedJobId !== jobId.value) return
|
|
|
|
|
if (payload.jobId && expectedJobId && payload.jobId !== expectedJobId) return
|
|
|
|
|
function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
|
|
|
|
|
if (payload.message) editStatusMessage.value = payload.message
|
|
|
|
|
editStatusBusy.value = payload.type === 'busy' || payload.busy === true
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
|
|
|
|
|
const complete = payload.type === 'complete' || payload.status === 'complete'
|
|
|
|
|
if (editSettledUi) return
|
|
|
|
|
|
|
|
|
|
if (complete && payload.stillId) {
|
|
|
|
|
editSettledUi = true
|
|
|
|
|
const dest = folders.value.find(folder => folder.id === folderId.value)
|
|
|
|
|
const locked = dest
|
|
|
|
|
? Boolean(dest.protected && !dest.unlocked)
|
|
|
|
|
: (folderLocked || payload.folderLocked === true)
|
|
|
|
|
if (locked) {
|
|
|
|
|
editResultUrl.value = ''
|
|
|
|
|
editLockedSave.value = true
|
|
|
|
|
browseFolderId.value = folderId.value
|
|
|
|
|
editStatusMessage.value = 'Saved to the locked folder. Unlock it to view.'
|
|
|
|
|
} else {
|
|
|
|
|
editResultUrl.value = `/api/library/stills/${payload.stillId}`
|
|
|
|
|
editAwaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
|
|
|
|
editLockedSave.value = false
|
|
|
|
|
editStatusMessage.value = 'Edit finished'
|
|
|
|
|
}
|
|
|
|
|
editBusy.value = false
|
|
|
|
|
editStatusBusy.value = false
|
|
|
|
|
stopTimer('edit')
|
|
|
|
|
stopListen('edit')
|
|
|
|
|
clearActiveJob('edit')
|
|
|
|
|
loadLibrary().catch(() => null)
|
|
|
|
|
void pollComfyHealth()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (failed) {
|
|
|
|
|
editSettledUi = true
|
|
|
|
|
editBusy.value = false
|
|
|
|
|
editStatusBusy.value = false
|
|
|
|
|
stopTimer('edit')
|
|
|
|
|
stopListen('edit')
|
|
|
|
|
clearActiveJob('edit')
|
|
|
|
|
loadLibrary().catch(() => null)
|
|
|
|
|
void pollComfyHealth()
|
|
|
|
|
toast(payload.error || payload.message || 'Image edit failed')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
|
|
|
|
|
if (payload.message) statusMessage.value = payload.message
|
|
|
|
|
statusBusy.value = payload.type === 'busy' || payload.busy === true
|
|
|
|
|
if (typeof payload.progress === 'number') progress.value = payload.progress
|
|
|
|
@@ -2096,7 +2288,8 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|
|
|
|
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
|
|
|
|
|
const complete = payload.type === 'complete' || payload.status === 'complete'
|
|
|
|
|
const deferred = payload.type === 'deferred' || payload.status === 'deferred'
|
|
|
|
|
if (settledUi) return
|
|
|
|
|
if (videoSettledUi) return
|
|
|
|
|
if (payload.stillId) return
|
|
|
|
|
|
|
|
|
|
if (payload.type === 'checkpoint' && payload.clipId) {
|
|
|
|
|
completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0)
|
|
|
|
@@ -2111,53 +2304,31 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (deferred) {
|
|
|
|
|
settledUi = true
|
|
|
|
|
busy.value = false
|
|
|
|
|
videoSettledUi = true
|
|
|
|
|
videoBusy.value = false
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
stopTimer()
|
|
|
|
|
stopListen()
|
|
|
|
|
clearActiveJob()
|
|
|
|
|
stopTimer('video')
|
|
|
|
|
stopListen('video')
|
|
|
|
|
clearActiveJob('video')
|
|
|
|
|
loadLibrary().catch(() => null)
|
|
|
|
|
void pollComfyHealth()
|
|
|
|
|
toast(payload.message || 'Comfy was still busy. This job was saved under Held jobs.')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (complete && (payload.clipId || payload.filename || payload.stillId)) {
|
|
|
|
|
settledUi = true
|
|
|
|
|
if (complete && (payload.clipId || payload.filename)) {
|
|
|
|
|
videoSettledUi = true
|
|
|
|
|
const dest = folders.value.find(folder => folder.id === folderId.value)
|
|
|
|
|
const locked = dest
|
|
|
|
|
? Boolean(dest.protected && !dest.unlocked)
|
|
|
|
|
: (folderLocked || payload.folderLocked === true)
|
|
|
|
|
if (payload.stillId) {
|
|
|
|
|
if (locked) {
|
|
|
|
|
editResultUrl.value = ''
|
|
|
|
|
lockedSave.value = true
|
|
|
|
|
browseFolderId.value = folderId.value
|
|
|
|
|
statusMessage.value = 'Saved to the locked folder. Unlock it to view.'
|
|
|
|
|
} else {
|
|
|
|
|
editResultUrl.value = `/api/library/stills/${payload.stillId}`
|
|
|
|
|
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
|
|
|
|
concealOutput.value = awaitingReveal.value
|
|
|
|
|
lockedSave.value = false
|
|
|
|
|
statusMessage.value = 'Edit finished'
|
|
|
|
|
}
|
|
|
|
|
busy.value = false
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
stopTimer()
|
|
|
|
|
stopListen()
|
|
|
|
|
clearActiveJob()
|
|
|
|
|
loadLibrary().catch(() => null)
|
|
|
|
|
void pollComfyHealth()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const keepLibraryView = watchingLibrary.value && Boolean(currentClipId.value) && payload.clipId !== currentClipId.value
|
|
|
|
|
if (keepLibraryView) {
|
|
|
|
|
busy.value = false
|
|
|
|
|
videoBusy.value = false
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
stopTimer()
|
|
|
|
|
stopListen()
|
|
|
|
|
clearActiveJob()
|
|
|
|
|
stopTimer('video')
|
|
|
|
|
stopListen('video')
|
|
|
|
|
clearActiveJob('video')
|
|
|
|
|
activeDraftId = ''
|
|
|
|
|
loadLibrary().catch(() => null)
|
|
|
|
|
void pollComfyHealth()
|
|
|
|
@@ -2187,11 +2358,11 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|
|
|
|
concealOutput.value = awaitingReveal.value
|
|
|
|
|
lockedSave.value = false
|
|
|
|
|
}
|
|
|
|
|
busy.value = false
|
|
|
|
|
videoBusy.value = false
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
stopTimer()
|
|
|
|
|
stopListen()
|
|
|
|
|
clearActiveJob()
|
|
|
|
|
stopTimer('video')
|
|
|
|
|
stopListen('video')
|
|
|
|
|
clearActiveJob('video')
|
|
|
|
|
activeDraftId = ''
|
|
|
|
|
loadLibrary().catch(() => null)
|
|
|
|
|
void pollComfyHealth()
|
|
|
|
@@ -2199,68 +2370,96 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (failed) {
|
|
|
|
|
settledUi = true
|
|
|
|
|
busy.value = false
|
|
|
|
|
videoSettledUi = true
|
|
|
|
|
videoBusy.value = false
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
stopTimer()
|
|
|
|
|
stopListen()
|
|
|
|
|
clearActiveJob()
|
|
|
|
|
stopTimer('video')
|
|
|
|
|
stopListen('video')
|
|
|
|
|
clearActiveJob('video')
|
|
|
|
|
loadLibrary().catch(() => null)
|
|
|
|
|
void pollComfyHealth()
|
|
|
|
|
toast(payload.error || payload.message || 'Generation failed')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function listen(id: string, hidden: boolean, folderLocked = false) {
|
|
|
|
|
const gen = ++listenGen
|
|
|
|
|
stopListen()
|
|
|
|
|
source = new EventSource(`/api/generate/${id}/stream`)
|
|
|
|
|
function applyJobEvent(kind: 'video' | 'edit', payload: Record<string, any>, hidden: boolean, folderLocked: boolean, expectedJobId: string) {
|
|
|
|
|
const currentId = kind === 'edit' ? editJobId.value : videoJobId.value
|
|
|
|
|
if (expectedJobId && currentId && expectedJobId !== currentId) return
|
|
|
|
|
if (payload.jobId && expectedJobId && payload.jobId !== expectedJobId) return
|
|
|
|
|
if (kind === 'edit') applyEditEvent(payload, hidden, folderLocked)
|
|
|
|
|
else applyVideoEvent(payload, hidden, folderLocked)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function listen(kind: 'video' | 'edit', id: string, hidden: boolean, folderLocked = false) {
|
|
|
|
|
stopListen(kind)
|
|
|
|
|
const isEdit = kind === 'edit'
|
|
|
|
|
const gen = isEdit ? ++editListenGen : ++videoListenGen
|
|
|
|
|
const source = new EventSource(`/api/generate/${id}/stream`)
|
|
|
|
|
if (isEdit) editSource = source
|
|
|
|
|
else videoSource = source
|
|
|
|
|
source.onmessage = (event) => {
|
|
|
|
|
if (gen !== listenGen) return
|
|
|
|
|
applyJobEvent(JSON.parse(event.data), hidden, folderLocked, id)
|
|
|
|
|
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
|
|
|
|
|
applyJobEvent(kind, JSON.parse(event.data), hidden, folderLocked, id)
|
|
|
|
|
}
|
|
|
|
|
const tick = async () => {
|
|
|
|
|
if (gen !== listenGen || settledUi) {
|
|
|
|
|
if (gen === listenGen && settledUi) stopListen()
|
|
|
|
|
const currentGen = isEdit ? editListenGen : videoListenGen
|
|
|
|
|
const settled = isEdit ? editSettledUi : videoSettledUi
|
|
|
|
|
if (gen !== currentGen || settled) {
|
|
|
|
|
if (gen === currentGen && settled) stopListen(kind)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
|
|
|
|
|
if (gen !== listenGen) return
|
|
|
|
|
applyJobEvent(snap, hidden, folderLocked, id)
|
|
|
|
|
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
|
|
|
|
|
applyJobEvent(kind, snap, hidden, folderLocked, id)
|
|
|
|
|
} catch {
|
|
|
|
|
if (gen !== listenGen) return
|
|
|
|
|
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
|
|
|
|
|
try {
|
|
|
|
|
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: { jobId: id }
|
|
|
|
|
})
|
|
|
|
|
if (gen !== listenGen) return
|
|
|
|
|
applyJobEvent(recovered, hidden, folderLocked, id)
|
|
|
|
|
if (gen !== (isEdit ? editListenGen : videoListenGen)) return
|
|
|
|
|
applyJobEvent(kind, recovered, hidden, folderLocked, id)
|
|
|
|
|
} catch {
|
|
|
|
|
// Job snapshot can 404 briefly after a restart; keep polling while busy.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
void tick()
|
|
|
|
|
pollTimer = setInterval(() => {
|
|
|
|
|
const poll = setInterval(() => {
|
|
|
|
|
void tick()
|
|
|
|
|
}, 1000)
|
|
|
|
|
if (isEdit) editPollTimer = poll
|
|
|
|
|
else videoPollTimer = poll
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function cancelJob() {
|
|
|
|
|
const kind = studioMode.value === 'edit' ? 'edit' : 'video'
|
|
|
|
|
const id = kind === 'edit' ? editJobId.value : videoJobId.value
|
|
|
|
|
try {
|
|
|
|
|
await $fetch('/api/interrupt', { method: 'POST', body: { jobId: jobId.value } })
|
|
|
|
|
statusMessage.value = 'Job cancelled. ComfyUI is idle — generate again.'
|
|
|
|
|
await $fetch('/api/interrupt', { method: 'POST', body: { jobId: id, kind } })
|
|
|
|
|
if (kind === 'edit') editStatusMessage.value = 'Edit cancelled. Sidecar is idle — edit again.'
|
|
|
|
|
else statusMessage.value = 'Job cancelled. ComfyUI is idle — generate again.'
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
toast(error?.statusMessage || error?.data?.statusMessage || 'Failed to interrupt ComfyUI')
|
|
|
|
|
statusMessage.value = 'Cancelled locally. Generate again when ComfyUI is free.'
|
|
|
|
|
if (kind === 'edit') editStatusMessage.value = 'Cancelled locally. Edit again when the sidecar is free.'
|
|
|
|
|
else statusMessage.value = 'Cancelled locally. Generate again when ComfyUI is free.'
|
|
|
|
|
}
|
|
|
|
|
settledUi = true
|
|
|
|
|
busy.value = false
|
|
|
|
|
if (kind === 'edit') {
|
|
|
|
|
editSettledUi = true
|
|
|
|
|
editBusy.value = false
|
|
|
|
|
editStatusBusy.value = false
|
|
|
|
|
stopTimer('edit')
|
|
|
|
|
stopListen('edit')
|
|
|
|
|
clearActiveJob('edit')
|
|
|
|
|
} else {
|
|
|
|
|
videoSettledUi = true
|
|
|
|
|
videoBusy.value = false
|
|
|
|
|
statusBusy.value = false
|
|
|
|
|
stopTimer()
|
|
|
|
|
stopListen()
|
|
|
|
|
clearActiveJob()
|
|
|
|
|
stopTimer('video')
|
|
|
|
|
stopListen('video')
|
|
|
|
|
clearActiveJob('video')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|