Ship extension queues, library extend, and smoother seam stitching.
Users can pre-queue extensions, extend from the library with p2 titles, blend seams with xfade, and a unreadable catalog is no longer replaced with an empty one. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+176
-17
@@ -67,6 +67,61 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in extensionQueue"
|
||||||
|
: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">Extension #{{ index + 1 }}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-red-300 hover:text-red-200"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="removeQueuedExtension(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="Motion prompt for this extension…"
|
||||||
|
/>
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Duration</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl border px-3 py-2 text-sm"
|
||||||
|
:class="item.duration === 3 ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'"
|
||||||
|
@click="item.duration = 3"
|
||||||
|
>
|
||||||
|
3s
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl border px-3 py-2 text-sm"
|
||||||
|
:class="item.duration === 5 ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'"
|
||||||
|
@click="item.duration = 5"
|
||||||
|
>
|
||||||
|
5s
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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="busy || extensionQueue.length >= MAX_EXTENSIONS"
|
||||||
|
@click="queueExtension"
|
||||||
|
>
|
||||||
|
+ Queue Extension
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-2 text-sm font-medium text-zinc-300">Save to library</p>
|
<p class="mb-2 text-sm font-medium text-zinc-300">Save to library</p>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -249,10 +304,10 @@
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
:disabled="busy || !file || !prompt.trim() || !folderId || !queueReady"
|
||||||
@click="generate"
|
@click="generate"
|
||||||
>
|
>
|
||||||
{{ busy ? 'Generating…' : 'Generate Video' }}
|
{{ generateLabel }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="busy"
|
v-if="busy"
|
||||||
@@ -275,7 +330,7 @@
|
|||||||
>
|
>
|
||||||
<button type="button" class="min-w-0 flex-1 text-left" @click="loadDraft(draft)">
|
<button type="button" class="min-w-0 flex-1 text-left" @click="loadDraft(draft)">
|
||||||
<p class="truncate text-sm font-medium text-zinc-100">{{ draft.name || draft.prompt }}</p>
|
<p class="truncate text-sm font-medium text-zinc-100">{{ draft.name || draft.prompt }}</p>
|
||||||
<p class="mt-0.5 text-xs text-zinc-500">{{ draft.duration }}s · {{ draft.turbo ? 'turbo' : 'quality' }} · {{ draft.sound === false ? 'silent' : 'sound' }} · seed {{ draft.seed }}</p>
|
<p class="mt-0.5 text-xs text-zinc-500">{{ draft.duration }}s · {{ draft.turbo ? 'turbo' : 'quality' }} · {{ draft.sound === false ? 'silent' : 'sound' }} · seed {{ draft.seed }}{{ draft.extensions?.length ? ` · +${draft.extensions.length} ext` : '' }}</p>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="shrink-0 text-xs text-red-300 hover:text-red-200" @click="dismissDraft(draft)">Dismiss</button>
|
<button type="button" class="shrink-0 text-xs text-red-300 hover:text-red-200" @click="dismissDraft(draft)">Dismiss</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -287,13 +342,20 @@
|
|||||||
<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">
|
||||||
<h2 class="font-display text-2xl font-bold">Output</h2>
|
<h2 class="font-display text-2xl font-bold">Output</h2>
|
||||||
<p class="text-xs text-zinc-500">{{ elapsedLabel }} · step {{ step }}/{{ maxStep || (turbo ? 8 : 20) }}</p>
|
<p class="text-xs text-zinc-500">
|
||||||
|
{{ elapsedLabel }}
|
||||||
|
<template v-if="chainTotal > 1"> · chain {{ chainStep }}/{{ chainTotal }}</template>
|
||||||
|
· step {{ step }}/{{ maxStep || (turbo ? 8 : 20) }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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 text-sm">
|
||||||
<span :class="statusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ statusMessage }}</span>
|
<span :class="statusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ statusMessage }}</span>
|
||||||
<span class="text-amber-200">{{ progress }}%</span>
|
<span class="text-amber-200">{{ progress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="chainTotal > 1" class="mb-2 text-xs text-amber-200/80">
|
||||||
|
Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }}
|
||||||
|
</p>
|
||||||
<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: progress + '%' }" />
|
||||||
</div>
|
</div>
|
||||||
@@ -338,8 +400,9 @@
|
|||||||
Generated MP4 appears here
|
Generated MP4 appears here
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="videoUrl && !awaitingReveal && !lockedSave" class="mt-4 flex flex-wrap gap-3">
|
<div v-if="videoUrl && !lockedSave" class="mt-4 flex flex-wrap gap-3">
|
||||||
<a
|
<a
|
||||||
|
v-if="!awaitingReveal"
|
||||||
:href="videoUrl"
|
:href="videoUrl"
|
||||||
:download="`${outputDownloadName}.mp4`"
|
:download="`${outputDownloadName}.mp4`"
|
||||||
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
|
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
|
||||||
@@ -350,17 +413,16 @@
|
|||||||
v-if="canExtend"
|
v-if="canExtend"
|
||||||
type="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"
|
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
|
||||||
:disabled="busy"
|
@click="openExtend()"
|
||||||
@click="openExtend"
|
|
||||||
>
|
>
|
||||||
Extend Video
|
Extend Video
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="extendOpen && canExtend" class="mt-4 space-y-4 rounded-2xl border border-white/10 bg-zinc-950/50 p-4">
|
<div v-if="extendOpen" class="mt-4 space-y-4 rounded-2xl border border-white/10 bg-zinc-950/50 p-4">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-amber-100">Extend this clip</h3>
|
<h3 class="text-sm font-semibold text-amber-100">Extend this clip</h3>
|
||||||
<p class="mt-1 text-xs text-zinc-500">Continue from the last frame with a new motion prompt, then stitch the segments together.</p>
|
<p class="mt-1 text-xs text-zinc-500">Continue from the last frame with a new motion prompt, then stitch the segments together.{{ extendSaveName ? ` Saves as “${extendSaveName}”.` : '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="text-xs text-zinc-400 hover:text-white" @click="closeExtend">Close</button>
|
<button type="button" class="text-xs text-zinc-400 hover:text-white" @click="closeExtend">Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -403,7 +465,7 @@
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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="busy || !extendPrompt.trim() || lastFrameLoading"
|
:disabled="!extendPrompt.trim() || lastFrameLoading"
|
||||||
@click="generateExtension"
|
@click="generateExtension"
|
||||||
>
|
>
|
||||||
Generate Extension
|
Generate Extension
|
||||||
@@ -495,6 +557,7 @@
|
|||||||
<p class="text-xs text-zinc-500">{{ item.width && item.height ? `${item.width}×${item.height}` : 'size unknown' }} · {{ item.steps || '—' }} steps · seed {{ item.seed || '—' }}</p>
|
<p class="text-xs text-zinc-500">{{ item.width && item.height ? `${item.width}×${item.height}` : 'size unknown' }} · {{ item.steps || '—' }} steps · seed {{ item.seed || '—' }}</p>
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||||
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button>
|
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button>
|
||||||
|
<button type="button" class="text-xs text-amber-300" @click="openExtend(item)">Extend</button>
|
||||||
<button type="button" class="text-xs text-amber-300" @click="startRename(item)">Rename</button>
|
<button type="button" class="text-xs text-amber-300" @click="startRename(item)">Rename</button>
|
||||||
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
|
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
|
||||||
<select
|
<select
|
||||||
@@ -621,6 +684,13 @@ interface RetryDraft {
|
|||||||
fps?: number
|
fps?: number
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
|
extensions?: { prompt: string; duration: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueuedExtension {
|
||||||
|
id: string
|
||||||
|
prompt: string
|
||||||
|
duration: 3 | 5
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
|
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
|
||||||
@@ -642,6 +712,8 @@ const file = ref<File | null>(null)
|
|||||||
const preview = ref('')
|
const preview = ref('')
|
||||||
const dragging = ref(false)
|
const dragging = ref(false)
|
||||||
const prompt = ref('')
|
const prompt = ref('')
|
||||||
|
const MAX_EXTENSIONS = 8
|
||||||
|
const extensionQueue = ref<QueuedExtension[]>([])
|
||||||
const clipName = ref('')
|
const clipName = ref('')
|
||||||
const outputDownloadName = ref('aigen')
|
const outputDownloadName = ref('aigen')
|
||||||
const aspect = ref<AspectId>('auto')
|
const aspect = ref<AspectId>('auto')
|
||||||
@@ -680,6 +752,9 @@ const statusMessage = ref('Waiting for a still.')
|
|||||||
const statusBusy = ref(false)
|
const statusBusy = ref(false)
|
||||||
const step = ref(0)
|
const step = ref(0)
|
||||||
const maxStep = ref(8)
|
const maxStep = ref(8)
|
||||||
|
const chainStep = ref(1)
|
||||||
|
const chainTotal = ref(1)
|
||||||
|
const chainLabel = ref('')
|
||||||
const elapsedMs = ref(0)
|
const elapsedMs = ref(0)
|
||||||
const videoUrl = ref('')
|
const videoUrl = ref('')
|
||||||
const jobId = ref('')
|
const jobId = ref('')
|
||||||
@@ -689,7 +764,9 @@ const extendPrompt = ref('')
|
|||||||
const extendDuration = ref(5)
|
const extendDuration = ref(5)
|
||||||
const lastFrameUrl = ref('')
|
const lastFrameUrl = ref('')
|
||||||
const lastFrameLoading = ref(false)
|
const lastFrameLoading = ref(false)
|
||||||
|
const watchingLibrary = ref(false)
|
||||||
let lastFrameObjectUrl = ''
|
let lastFrameObjectUrl = ''
|
||||||
|
let lastFrameGen = 0
|
||||||
const awaitingReveal = ref(false)
|
const awaitingReveal = ref(false)
|
||||||
const concealOutput = ref(false)
|
const concealOutput = ref(false)
|
||||||
const lockedSave = ref(false)
|
const lockedSave = ref(false)
|
||||||
@@ -794,9 +871,16 @@ const currentClip = computed(() => clips.value.find(clip => clip.id === currentC
|
|||||||
const canExtend = computed(() => Boolean(
|
const canExtend = computed(() => Boolean(
|
||||||
videoUrl.value
|
videoUrl.value
|
||||||
&& !lockedSave.value
|
&& !lockedSave.value
|
||||||
&& !awaitingReveal.value
|
|
||||||
&& (currentClipId.value || clipIdFromPlayer())
|
&& (currentClipId.value || clipIdFromPlayer())
|
||||||
))
|
))
|
||||||
|
const extendSaveName = computed(() => nextClipPartName(currentClip.value?.name || currentClip.value?.prompt || ''))
|
||||||
|
const queueReady = computed(() => extensionQueue.value.every(item => item.prompt.trim()))
|
||||||
|
const generateLabel = computed(() => {
|
||||||
|
const n = extensionQueue.value.length
|
||||||
|
if (busy.value) return 'Generating…'
|
||||||
|
if (!n) return 'Generate Video'
|
||||||
|
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
|
||||||
|
})
|
||||||
|
|
||||||
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value))
|
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value))
|
||||||
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
|
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
|
||||||
@@ -1140,6 +1224,16 @@ function clipFileName(name: string) {
|
|||||||
return name.replace(/[<>:"/\\|?*'`]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || 'aigen'
|
return name.replace(/[<>:"/\\|?*'`]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || 'aigen'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextClipPartName(name: string) {
|
||||||
|
const trimmed = (name || '').trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
const match = trimmed.match(/^(.*)\s+p(\d+)$/i)
|
||||||
|
const base = (match ? match[1] : trimmed).trim() || trimmed
|
||||||
|
const part = match ? Number(match[2]) + 1 : 2
|
||||||
|
const suffix = ` p${part}`
|
||||||
|
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
function rerun(item: LibraryClip) {
|
function rerun(item: LibraryClip) {
|
||||||
prompt.value = item.prompt
|
prompt.value = item.prompt
|
||||||
clipName.value = item.name || ''
|
clipName.value = item.name || ''
|
||||||
@@ -1162,6 +1256,11 @@ async function loadDraft(draft: RetryDraft) {
|
|||||||
if (draft.hideInput) hideInputPreview.value = true
|
if (draft.hideInput) hideInputPreview.value = true
|
||||||
duration.value = clampDuration(Number(draft.duration))
|
duration.value = clampDuration(Number(draft.duration))
|
||||||
durationDraft.value = formatDuration(duration.value)
|
durationDraft.value = formatDuration(duration.value)
|
||||||
|
extensionQueue.value = (draft.extensions || []).slice(0, MAX_EXTENSIONS).map(item => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
prompt: String(item.prompt || ''),
|
||||||
|
duration: item.duration === 3 ? 3 : 5 as 3 | 5
|
||||||
|
}))
|
||||||
try {
|
try {
|
||||||
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
|
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
|
||||||
const filename = draft.stillFilename || 'held-still.png'
|
const filename = draft.stillFilename || 'held-still.png'
|
||||||
@@ -1228,6 +1327,7 @@ function clipVideoUrl(clipId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeExtend() {
|
function closeExtend() {
|
||||||
|
lastFrameGen += 1
|
||||||
extendOpen.value = false
|
extendOpen.value = false
|
||||||
lastFrameLoading.value = false
|
lastFrameLoading.value = false
|
||||||
if (lastFrameObjectUrl) {
|
if (lastFrameObjectUrl) {
|
||||||
@@ -1237,37 +1337,43 @@ function closeExtend() {
|
|||||||
lastFrameUrl.value = ''
|
lastFrameUrl.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openExtend() {
|
async function openExtend(item?: LibraryClip) {
|
||||||
const id = clipIdFromPlayer()
|
if (item) openClip(item)
|
||||||
|
const id = item?.id || clipIdFromPlayer()
|
||||||
if (!id) {
|
if (!id) {
|
||||||
toast('Load a generated clip before extending it.')
|
toast('Load a generated clip before extending it.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentClipId.value = id
|
currentClipId.value = id
|
||||||
const clip = clips.value.find(item => item.id === id)
|
const clip = clips.value.find(entry => entry.id === id)
|
||||||
extendPrompt.value = clip?.prompt || prompt.value
|
extendPrompt.value = clip?.prompt || prompt.value
|
||||||
extendDuration.value = 5
|
extendDuration.value = 5
|
||||||
extendOpen.value = true
|
extendOpen.value = true
|
||||||
lastFrameLoading.value = true
|
lastFrameLoading.value = true
|
||||||
lastFrameUrl.value = ''
|
lastFrameUrl.value = ''
|
||||||
player.value?.pause()
|
player.value?.pause()
|
||||||
|
const gen = ++lastFrameGen
|
||||||
try {
|
try {
|
||||||
const blob = await $fetch<Blob>(`/api/library/clips/${id}/last-frame`, { responseType: 'blob' })
|
const blob = await $fetch<Blob>(`/api/library/clips/${id}/last-frame`, { responseType: 'blob' })
|
||||||
|
if (gen !== lastFrameGen) return
|
||||||
if (lastFrameObjectUrl) URL.revokeObjectURL(lastFrameObjectUrl)
|
if (lastFrameObjectUrl) URL.revokeObjectURL(lastFrameObjectUrl)
|
||||||
lastFrameObjectUrl = URL.createObjectURL(blob)
|
lastFrameObjectUrl = URL.createObjectURL(blob)
|
||||||
lastFrameUrl.value = lastFrameObjectUrl
|
lastFrameUrl.value = lastFrameObjectUrl
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (gen !== lastFrameGen) return
|
||||||
toast(error?.data?.statusMessage || 'Could not extract the last frame')
|
toast(error?.data?.statusMessage || 'Could not extract the last frame')
|
||||||
} finally {
|
} finally {
|
||||||
lastFrameLoading.value = false
|
if (gen === lastFrameGen) lastFrameLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateExtension() {
|
async function generateExtension() {
|
||||||
const id = clipIdFromPlayer()
|
const id = clipIdFromPlayer()
|
||||||
if (!id || !extendPrompt.value.trim()) return
|
if (!id || !extendPrompt.value.trim()) return
|
||||||
|
const switching = busy.value
|
||||||
statusBusy.value = false
|
statusBusy.value = false
|
||||||
busy.value = true
|
busy.value = true
|
||||||
|
watchingLibrary.value = false
|
||||||
listenGen += 1
|
listenGen += 1
|
||||||
stopListen()
|
stopListen()
|
||||||
settledUi = false
|
settledUi = false
|
||||||
@@ -1275,6 +1381,9 @@ async function generateExtension() {
|
|||||||
awaitingReveal.value = false
|
awaitingReveal.value = false
|
||||||
concealOutput.value = hideThumbnail.value || currentClip.value?.hideThumbnail === true
|
concealOutput.value = hideThumbnail.value || currentClip.value?.hideThumbnail === true
|
||||||
lockedSave.value = false
|
lockedSave.value = false
|
||||||
|
chainStep.value = 1
|
||||||
|
chainTotal.value = 1
|
||||||
|
chainLabel.value = ''
|
||||||
statusMessage.value = 'Extracting last frame & stitching extension...'
|
statusMessage.value = 'Extracting last frame & stitching extension...'
|
||||||
startTimer()
|
startTimer()
|
||||||
try {
|
try {
|
||||||
@@ -1287,6 +1396,7 @@ async function generateExtension() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
closeExtend()
|
closeExtend()
|
||||||
|
if (switching) toast('Switched to this extension. The other job will keep saving to the library.')
|
||||||
jobId.value = started.jobId
|
jobId.value = started.jobId
|
||||||
maxStep.value = started.steps
|
maxStep.value = started.steps
|
||||||
settledUi = false
|
settledUi = false
|
||||||
@@ -1306,6 +1416,7 @@ async function generateExtension() {
|
|||||||
|
|
||||||
function openClip(item: LibraryClip) {
|
function openClip(item: LibraryClip) {
|
||||||
closeExtend()
|
closeExtend()
|
||||||
|
watchingLibrary.value = true
|
||||||
currentClipId.value = item.id
|
currentClipId.value = item.id
|
||||||
lockedSave.value = false
|
lockedSave.value = false
|
||||||
concealOutput.value = item.hideThumbnail
|
concealOutput.value = item.hideThumbnail
|
||||||
@@ -1314,8 +1425,10 @@ function openClip(item: LibraryClip) {
|
|||||||
playerSize.value = { width: item.width || 1344, height: item.height || 768 }
|
playerSize.value = { width: item.width || 1344, height: item.height || 768 }
|
||||||
browseFolderId.value = item.folderId
|
browseFolderId.value = item.folderId
|
||||||
outputDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen')
|
outputDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen')
|
||||||
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
|
if (!busy.value) {
|
||||||
progress.value = 100
|
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
|
||||||
|
progress.value = 100
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeClip(item: LibraryClip) {
|
async function removeClip(item: LibraryClip) {
|
||||||
@@ -1407,8 +1520,25 @@ function revealAndPlay() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queueExtension() {
|
||||||
|
if (extensionQueue.value.length >= MAX_EXTENSIONS) return
|
||||||
|
extensionQueue.value.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
prompt: prompt.value.trim(),
|
||||||
|
duration: 5
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQueuedExtension(id: string) {
|
||||||
|
extensionQueue.value = extensionQueue.value.filter(item => item.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
if (!file.value || !prompt.value.trim()) return
|
if (!file.value || !prompt.value.trim()) return
|
||||||
|
if (!queueReady.value) {
|
||||||
|
toast('Each queued extension needs a prompt.')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!folderId.value) {
|
if (!folderId.value) {
|
||||||
toast('Choose a library folder before generating.')
|
toast('Choose a library folder before generating.')
|
||||||
return
|
return
|
||||||
@@ -1425,8 +1555,12 @@ async function generate() {
|
|||||||
awaitingReveal.value = false
|
awaitingReveal.value = false
|
||||||
concealOutput.value = hideThumbnail.value
|
concealOutput.value = hideThumbnail.value
|
||||||
lockedSave.value = false
|
lockedSave.value = false
|
||||||
|
watchingLibrary.value = false
|
||||||
playerSize.value = outputSize.value
|
playerSize.value = outputSize.value
|
||||||
statusMessage.value = 'Checking ComfyUI...'
|
statusMessage.value = 'Checking ComfyUI...'
|
||||||
|
chainStep.value = 1
|
||||||
|
chainTotal.value = 1 + extensionQueue.value.length
|
||||||
|
chainLabel.value = extensionQueue.value.length ? 'Initial' : ''
|
||||||
startTimer()
|
startTimer()
|
||||||
try {
|
try {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
@@ -1449,6 +1583,12 @@ async function generate() {
|
|||||||
body.append('sound', String(withSound.value))
|
body.append('sound', String(withSound.value))
|
||||||
body.append('name', clipName.value.trim())
|
body.append('name', clipName.value.trim())
|
||||||
if (activeDraftId) body.append('draftId', activeDraftId)
|
if (activeDraftId) body.append('draftId', activeDraftId)
|
||||||
|
if (extensionQueue.value.length) {
|
||||||
|
body.append('extensions', JSON.stringify(extensionQueue.value.map(item => ({
|
||||||
|
prompt: item.prompt.trim(),
|
||||||
|
duration: item.duration
|
||||||
|
}))))
|
||||||
|
}
|
||||||
outputDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.trim().split(/[.!?\n]/)[0] || 'aigen')
|
outputDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.trim().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', {
|
const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1490,6 +1630,9 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|||||||
if (typeof payload.progress === 'number') progress.value = payload.progress
|
if (typeof payload.progress === 'number') progress.value = payload.progress
|
||||||
if (typeof payload.step === 'number') step.value = payload.step
|
if (typeof payload.step === 'number') step.value = payload.step
|
||||||
if (typeof payload.maxStep === 'number') maxStep.value = payload.maxStep
|
if (typeof payload.maxStep === 'number') maxStep.value = payload.maxStep
|
||||||
|
if (typeof payload.chainStep === 'number') chainStep.value = payload.chainStep
|
||||||
|
if (typeof payload.chainTotal === 'number') chainTotal.value = payload.chainTotal
|
||||||
|
if (payload.chainLabel) chainLabel.value = String(payload.chainLabel)
|
||||||
|
|
||||||
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
|
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
|
||||||
const complete = payload.type === 'complete' || payload.status === 'complete'
|
const complete = payload.type === 'complete' || payload.status === 'complete'
|
||||||
@@ -1515,6 +1658,22 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|||||||
const locked = dest
|
const locked = dest
|
||||||
? Boolean(dest.protected && !dest.unlocked)
|
? Boolean(dest.protected && !dest.unlocked)
|
||||||
: (folderLocked || payload.folderLocked === true)
|
: (folderLocked || payload.folderLocked === true)
|
||||||
|
const keepLibraryView = watchingLibrary.value && Boolean(currentClipId.value) && payload.clipId !== currentClipId.value
|
||||||
|
if (keepLibraryView) {
|
||||||
|
busy.value = false
|
||||||
|
statusBusy.value = false
|
||||||
|
stopTimer()
|
||||||
|
stopListen()
|
||||||
|
clearActiveJob()
|
||||||
|
activeDraftId = ''
|
||||||
|
loadLibrary().catch(() => null)
|
||||||
|
void pollComfyHealth()
|
||||||
|
statusMessage.value = currentClip.value?.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
|
||||||
|
toast(locked
|
||||||
|
? 'Saved to the locked folder. Unlock it to view.'
|
||||||
|
: 'New clip saved to the library')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (locked) {
|
if (locked) {
|
||||||
videoUrl.value = ''
|
videoUrl.value = ''
|
||||||
awaitingReveal.value = false
|
awaitingReveal.value = false
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const length = frameLength(durationSeconds, fps)
|
const length = frameLength(durationSeconds, fps)
|
||||||
const width = source.width || 1344
|
const width = source.width || 1344
|
||||||
const height = source.height || 768
|
const height = source.height || 768
|
||||||
const clipName = `${clipTitle(source)} extended`.slice(0, 80)
|
const clipName = nextClipPartName(clipTitle(source))
|
||||||
|
|
||||||
const job = createJob()
|
const job = createJob()
|
||||||
job.maxStep = steps
|
job.maxStep = steps
|
||||||
|
|||||||
+191
-37
@@ -1,3 +1,6 @@
|
|||||||
|
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
function isComfyBusyTimeout(error: unknown) {
|
function isComfyBusyTimeout(error: unknown) {
|
||||||
const err = error as { statusCode?: number; status?: number; data?: { code?: string }; message?: string; statusMessage?: string }
|
const err = error as { statusCode?: number; status?: number; data?: { code?: string }; message?: string; statusMessage?: string }
|
||||||
if (err?.statusCode === 409 || err?.status === 409 || err?.data?.code === 'COMFY_BUSY') return true
|
if (err?.statusCode === 409 || err?.status === 409 || err?.data?.code === 'COMFY_BUSY') return true
|
||||||
@@ -11,6 +14,28 @@ function parseDuration(raw: string) {
|
|||||||
return Math.min(120, Math.max(0.5, seconds))
|
return Math.min(120, Math.max(0.5, seconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseExtendDuration(raw: unknown) {
|
||||||
|
const seconds = Number(raw)
|
||||||
|
return seconds === 3 || seconds === 5 ? seconds : 5
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExtensions(raw: string | undefined) {
|
||||||
|
if (!raw) return [] as { prompt: string; duration: number }[]
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
return parsed
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((item: { prompt?: unknown; duration?: unknown }) => ({
|
||||||
|
prompt: String(item?.prompt || '').trim(),
|
||||||
|
duration: parseExtendDuration(item?.duration)
|
||||||
|
}))
|
||||||
|
.filter(item => item.prompt)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
|
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
|
||||||
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
|
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
|
||||||
|
|
||||||
@@ -39,6 +64,19 @@ function parseScheduler(raw: string | undefined) {
|
|||||||
return SCHEDULERS.has(raw || '') ? raw! : 'simple'
|
return SCHEDULERS.has(raw || '') ? raw! : 'simple'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertJobActive(job: ReturnType<typeof createJob>) {
|
||||||
|
if (job.status === 'cancelled') {
|
||||||
|
throw new Error('Job interrupted.')
|
||||||
|
}
|
||||||
|
if (job.status === 'error') {
|
||||||
|
throw new Error(job.error || 'Generation failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const form = await readMultipartFormData(event)
|
const form = await readMultipartFormData(event)
|
||||||
if (!form?.length) {
|
if (!form?.length) {
|
||||||
@@ -62,6 +100,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
|
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
|
||||||
}
|
}
|
||||||
|
const extensions = parseExtensions(fields.extensions)
|
||||||
|
|
||||||
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
|
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
|
||||||
const turbo = fields.turbo !== 'false'
|
const turbo = fields.turbo !== 'false'
|
||||||
@@ -100,6 +139,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
hideInput
|
hideInput
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const chainTotal = 1 + extensions.length
|
||||||
const job = createJob()
|
const job = createJob()
|
||||||
job.maxStep = steps
|
job.maxStep = steps
|
||||||
job.hideThumbnail = hideThumbnail
|
job.hideThumbnail = hideThumbnail
|
||||||
@@ -126,11 +166,32 @@ export default defineEventHandler(async (event) => {
|
|||||||
stillFilename: still.filename,
|
stillFilename: still.filename,
|
||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
sound,
|
sound,
|
||||||
draftId: (fields.draftId || '').trim() || undefined
|
draftId: (fields.draftId || '').trim() || undefined,
|
||||||
|
extensions,
|
||||||
|
chainIndex: 0,
|
||||||
|
chainStep: 1,
|
||||||
|
chainTotal,
|
||||||
|
chainLabel: extensions.length ? 'Initial' : undefined
|
||||||
}
|
}
|
||||||
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
||||||
|
|
||||||
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length, sound, cfg, fps, samplerName, scheduler }).catch(async (error) => {
|
void runGeneration(job, {
|
||||||
|
prompt,
|
||||||
|
image,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
steps,
|
||||||
|
seed,
|
||||||
|
turbo,
|
||||||
|
length,
|
||||||
|
sound,
|
||||||
|
cfg,
|
||||||
|
fps,
|
||||||
|
samplerName,
|
||||||
|
scheduler,
|
||||||
|
extensions
|
||||||
|
}).catch(async (error) => {
|
||||||
|
removeExtendTemp(job.library?.extendTmpDir)
|
||||||
if (isComfyBusyTimeout(error) && job.library) {
|
if (isComfyBusyTimeout(error) && job.library) {
|
||||||
try {
|
try {
|
||||||
const draft = await saveRetryDraft({
|
const draft = await saveRetryDraft({
|
||||||
@@ -154,7 +215,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
cfg: job.library.cfg,
|
cfg: job.library.cfg,
|
||||||
fps: job.library.fps,
|
fps: job.library.fps,
|
||||||
samplerName: job.library.samplerName,
|
samplerName: job.library.samplerName,
|
||||||
scheduler: job.library.scheduler
|
scheduler: job.library.scheduler,
|
||||||
|
extensions: job.library.extensions
|
||||||
})
|
})
|
||||||
job.library.draftId = draft.id
|
job.library.draftId = draft.id
|
||||||
job.status = 'deferred'
|
job.status = 'deferred'
|
||||||
@@ -165,6 +227,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
// fall through to a normal error if the held job could not be saved
|
// fall through to a normal error if the held job could not be saved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (job.status === 'error' || job.status === 'cancelled' || job.status === 'deferred') return
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
job.status = 'error'
|
job.status = 'error'
|
||||||
job.error = message
|
job.error = message
|
||||||
@@ -186,48 +249,53 @@ export default defineEventHandler(async (event) => {
|
|||||||
turbo,
|
turbo,
|
||||||
folderId,
|
folderId,
|
||||||
hideThumbnail,
|
hideThumbnail,
|
||||||
folderLocked
|
folderLocked,
|
||||||
|
chainTotal
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function runGeneration(
|
type GenerateParams = {
|
||||||
job: ReturnType<typeof createJob>,
|
prompt: string
|
||||||
params: {
|
image: { filename: string; data: Buffer; type?: string }
|
||||||
prompt: string
|
width: number
|
||||||
image: { filename: string; data: Buffer; type?: string }
|
height: number
|
||||||
width: number
|
steps: number
|
||||||
height: number
|
seed: number
|
||||||
steps: number
|
turbo: boolean
|
||||||
seed: number
|
length: number
|
||||||
turbo: boolean
|
sound: boolean
|
||||||
length: number
|
cfg: number
|
||||||
sound: boolean
|
fps: number
|
||||||
cfg: number
|
samplerName: string
|
||||||
fps: number
|
scheduler: string
|
||||||
samplerName: string
|
extensions: { prompt: string; duration: number }[]
|
||||||
scheduler: string
|
}
|
||||||
}
|
|
||||||
) {
|
|
||||||
await ensureComfyReady((status) => {
|
|
||||||
emitJob(job, {
|
|
||||||
type: status.state === 'busy' ? 'busy' : 'status',
|
|
||||||
message: status.message,
|
|
||||||
progress: status.state === 'online' ? Math.max(job.progress, 3) : Math.max(job.progress, 1),
|
|
||||||
busy: status.state === 'busy',
|
|
||||||
queueRunning: status.queueRunning,
|
|
||||||
queuePending: status.queuePending
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const done = watchComfyJob(job)
|
async function queueMiniMax(
|
||||||
|
job: ReturnType<typeof createJob>,
|
||||||
|
params: Omit<GenerateParams, 'extensions'> & { persist: boolean }
|
||||||
|
) {
|
||||||
|
assertJobActive(job)
|
||||||
|
job.socketReady = false
|
||||||
|
job.promptId = undefined
|
||||||
|
job.video = undefined
|
||||||
|
job.segmentBuffer = undefined
|
||||||
|
|
||||||
|
const done = watchComfyJob(job, { persist: params.persist })
|
||||||
job.status = 'uploading'
|
job.status = 'uploading'
|
||||||
emitJob(job, { type: 'status', message: 'Uploading image to ComfyUI...', progress: 4 })
|
const uploading = (job.library?.chainIndex || 0) > 0
|
||||||
|
? 'Uploading last frame to ComfyUI...'
|
||||||
|
: 'Uploading image to ComfyUI...'
|
||||||
|
const queueing = (job.library?.chainIndex || 0) > 0
|
||||||
|
? 'Queueing extension on MiniMax H3...'
|
||||||
|
: 'Queueing MiniMax H3 job...'
|
||||||
|
emitChainJob(job, { type: 'status', message: uploading, progress: 4 })
|
||||||
const uploaded = await uploadImage(params.image, job.id)
|
const uploaded = await uploadImage(params.image, job.id)
|
||||||
if (job.library) {
|
if (job.library) {
|
||||||
job.library.imageName = uploaded.name
|
job.library.imageName = uploaded.name
|
||||||
job.library.imageSubfolder = uploaded.subfolder
|
job.library.imageSubfolder = uploaded.subfolder
|
||||||
}
|
}
|
||||||
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
|
emitChainJob(job, { type: 'status', message: queueing, progress: 6 })
|
||||||
await waitForComfySocket(job, 4000)
|
await waitForComfySocket(job, 4000)
|
||||||
|
|
||||||
const graph = buildWorkflow({
|
const graph = buildWorkflow({
|
||||||
@@ -278,6 +346,92 @@ async function runGeneration(
|
|||||||
extendPart1Path: job.library.extendPart1Path
|
extendPart1Path: job.library.extendPart1Path
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
emitChainJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||||
await done
|
await done
|
||||||
|
assertJobActive(job)
|
||||||
|
if (!params.persist && !job.segmentBuffer?.length) {
|
||||||
|
throw new Error('Segment finished without a video')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGeneration(
|
||||||
|
job: ReturnType<typeof createJob>,
|
||||||
|
params: GenerateParams
|
||||||
|
) {
|
||||||
|
const ready = (status: { state: string; message: string; queueRunning?: number; queuePending?: number }) => {
|
||||||
|
emitChainJob(job, {
|
||||||
|
type: status.state === 'busy' ? 'busy' : 'status',
|
||||||
|
message: status.message,
|
||||||
|
progress: status.state === 'online' ? 3 : 1,
|
||||||
|
busy: status.state === 'busy',
|
||||||
|
queueRunning: status.queueRunning,
|
||||||
|
queuePending: status.queuePending
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureComfyReady(ready)
|
||||||
|
|
||||||
|
const { extensions, ...base } = params
|
||||||
|
await queueMiniMax(job, {
|
||||||
|
...base,
|
||||||
|
persist: extensions.length === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!extensions.length || !job.library) return
|
||||||
|
|
||||||
|
const tmpDir = extendTempDir(job.library.ownerKey, job.id)
|
||||||
|
job.library.extendTmpDir = tmpDir
|
||||||
|
const currentPath = join(tmpDir, 'current.mp4')
|
||||||
|
const part1Path = join(tmpDir, 'part1.mp4')
|
||||||
|
const framePath = join(tmpDir, 'last_frame.png')
|
||||||
|
writeFileSync(currentPath, job.segmentBuffer!)
|
||||||
|
job.segmentBuffer = undefined
|
||||||
|
|
||||||
|
for (let i = 0; i < extensions.length; i++) {
|
||||||
|
assertJobActive(job)
|
||||||
|
const ext = extensions[i]
|
||||||
|
const isLast = i === extensions.length - 1
|
||||||
|
job.library.chainIndex = i + 1
|
||||||
|
job.library.chainStep = i + 2
|
||||||
|
job.library.chainLabel = `Extension ${i + 1}`
|
||||||
|
job.library.extendPart1Path = undefined
|
||||||
|
|
||||||
|
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
||||||
|
await sleep(3000)
|
||||||
|
assertJobActive(job)
|
||||||
|
|
||||||
|
copyFileSync(currentPath, part1Path)
|
||||||
|
await extractLastFrame(part1Path, framePath)
|
||||||
|
const frame = readFileSync(framePath)
|
||||||
|
job.library.extendPart1Path = part1Path
|
||||||
|
const sound = await probeHasAudio(currentPath)
|
||||||
|
const seed = Math.floor(Math.random() * 2_147_483_647)
|
||||||
|
|
||||||
|
await ensureComfyReady(ready)
|
||||||
|
await queueMiniMax(job, {
|
||||||
|
prompt: ext.prompt,
|
||||||
|
image: { filename: 'last_frame.png', data: frame, type: 'image/png' },
|
||||||
|
width: params.width,
|
||||||
|
height: params.height,
|
||||||
|
steps: params.steps,
|
||||||
|
seed,
|
||||||
|
turbo: params.turbo,
|
||||||
|
length: frameLength(ext.duration, params.fps),
|
||||||
|
sound,
|
||||||
|
cfg: params.cfg,
|
||||||
|
fps: params.fps,
|
||||||
|
samplerName: params.samplerName,
|
||||||
|
scheduler: params.scheduler,
|
||||||
|
persist: isLast
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isLast) {
|
||||||
|
if (!job.segmentBuffer?.length) {
|
||||||
|
throw new Error('Extension finished without a stitched video')
|
||||||
|
}
|
||||||
|
writeFileSync(currentPath, job.segmentBuffer)
|
||||||
|
job.segmentBuffer = undefined
|
||||||
|
job.library.extendPart1Path = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+143
-4
@@ -92,6 +92,132 @@ export async function probeHasAudio(filePath: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const XFADE_DURATION = 0.12
|
||||||
|
|
||||||
|
async function probeDuration(filePath: string) {
|
||||||
|
const out = await runFfprobe([
|
||||||
|
'-v', 'error',
|
||||||
|
'-show_entries', 'format=duration',
|
||||||
|
'-of', 'default=nokey=1:noprint_wrappers=1',
|
||||||
|
filePath
|
||||||
|
])
|
||||||
|
const duration = Number(out)
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) {
|
||||||
|
throw new Error('Could not read video duration')
|
||||||
|
}
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeFps(filePath: string) {
|
||||||
|
const out = await runFfprobe([
|
||||||
|
'-v', 'error',
|
||||||
|
'-select_streams', 'v:0',
|
||||||
|
'-show_entries', 'stream=avg_frame_rate,r_frame_rate',
|
||||||
|
'-of', 'default=nokey=1:noprint_wrappers=1',
|
||||||
|
filePath
|
||||||
|
])
|
||||||
|
const line = out.split(/\r?\n/).map(item => item.trim()).find(Boolean) || ''
|
||||||
|
const [num, den] = line.split('/').map(Number)
|
||||||
|
if (Number.isFinite(num) && Number.isFinite(den) && den > 0) return num / den
|
||||||
|
const fps = Number(line)
|
||||||
|
return Number.isFinite(fps) && fps > 0 ? fps : 24
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeSize(filePath: string) {
|
||||||
|
const out = await runFfprobe([
|
||||||
|
'-v', 'error',
|
||||||
|
'-select_streams', 'v:0',
|
||||||
|
'-show_entries', 'stream=width,height',
|
||||||
|
'-of', 'csv=p=0',
|
||||||
|
filePath
|
||||||
|
])
|
||||||
|
const [width, height] = out.split(',').map(Number)
|
||||||
|
return {
|
||||||
|
width: Number.isFinite(width) && width > 0 ? width : 1344,
|
||||||
|
height: Number.isFinite(height) && height > 0 ? height : 768
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trimLeadingFrame(inputPath: string, outputPath: string) {
|
||||||
|
const fps = await probeFps(inputPath)
|
||||||
|
const hasAudio = await probeHasAudio(inputPath)
|
||||||
|
const args = [
|
||||||
|
'-y',
|
||||||
|
'-i', inputPath,
|
||||||
|
'-vf', 'trim=start_frame=1,setpts=PTS-STARTPTS'
|
||||||
|
]
|
||||||
|
if (hasAudio) {
|
||||||
|
args.push('-af', `atrim=start=${(1 / fps).toFixed(6)},asetpts=PTS-STARTPTS`, '-c:a', 'aac')
|
||||||
|
} else {
|
||||||
|
args.push('-an')
|
||||||
|
}
|
||||||
|
args.push(
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-preset', 'fast',
|
||||||
|
'-crf', '17',
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
outputPath
|
||||||
|
)
|
||||||
|
await runFfmpeg(args, 180000)
|
||||||
|
if (!existsSync(outputPath)) {
|
||||||
|
throw new Error('FFmpeg did not write the trimmed extension segment')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function xfadeStitch(part1Path: string, part2Path: string, outputPath: string) {
|
||||||
|
const [duration1, duration2, fps, size, audio1, audio2] = await Promise.all([
|
||||||
|
probeDuration(part1Path),
|
||||||
|
probeDuration(part2Path),
|
||||||
|
probeFps(part1Path),
|
||||||
|
probeSize(part1Path),
|
||||||
|
probeHasAudio(part1Path),
|
||||||
|
probeHasAudio(part2Path)
|
||||||
|
])
|
||||||
|
const fade = Math.min(
|
||||||
|
XFADE_DURATION,
|
||||||
|
Math.max(0.04, duration1 - 0.05),
|
||||||
|
Math.max(0.04, duration2 - 0.05)
|
||||||
|
)
|
||||||
|
const offset = Math.max(0, Number((duration1 - fade).toFixed(3)))
|
||||||
|
const fpsLabel = Number.isInteger(fps) ? String(fps) : fps.toFixed(3)
|
||||||
|
const prepare = `fps=${fpsLabel},scale=${size.width}:${size.height}:flags=bicubic,format=yuv420p,setpts=PTS-STARTPTS`
|
||||||
|
const videoFilter = `[0:v]${prepare}[v0];[1:v]${prepare}[v1];[v0][v1]xfade=transition=fade:duration=${fade.toFixed(3)}:offset=${offset},setpts=PTS-STARTPTS[v]`
|
||||||
|
const encode = [
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-preset', 'fast',
|
||||||
|
'-crf', '17',
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
'-movflags', '+faststart'
|
||||||
|
]
|
||||||
|
if (audio1 && audio2) {
|
||||||
|
await runFfmpeg([
|
||||||
|
'-y',
|
||||||
|
'-i', part1Path,
|
||||||
|
'-i', part2Path,
|
||||||
|
'-filter_complex', `${videoFilter};[0:a]asetpts=PTS-STARTPTS[a0];[1:a]asetpts=PTS-STARTPTS[a1];[a0][a1]acrossfade=d=${fade.toFixed(3)}:c1=tri:c2=tri[a]`,
|
||||||
|
'-map', '[v]',
|
||||||
|
'-map', '[a]',
|
||||||
|
...encode,
|
||||||
|
'-c:a', 'aac',
|
||||||
|
outputPath
|
||||||
|
], 300000)
|
||||||
|
} else {
|
||||||
|
await runFfmpeg([
|
||||||
|
'-y',
|
||||||
|
'-i', part1Path,
|
||||||
|
'-i', part2Path,
|
||||||
|
'-filter_complex', videoFilter,
|
||||||
|
'-map', '[v]',
|
||||||
|
...encode,
|
||||||
|
outputPath
|
||||||
|
], 300000)
|
||||||
|
}
|
||||||
|
if (!existsSync(outputPath)) {
|
||||||
|
throw new Error('FFmpeg did not write the blended video')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function concatMp4(part1Path: string, part2Path: string, outputPath: string) {
|
export async function concatMp4(part1Path: string, part2Path: string, outputPath: string) {
|
||||||
const listPath = join(dirname(outputPath), 'concat_list.txt')
|
const listPath = join(dirname(outputPath), 'concat_list.txt')
|
||||||
writeFileSync(listPath, `file '${concatPath(part1Path)}'\nfile '${concatPath(part2Path)}'\n`)
|
writeFileSync(listPath, `file '${concatPath(part1Path)}'\nfile '${concatPath(part2Path)}'\n`)
|
||||||
@@ -111,7 +237,7 @@ export async function concatMp4(part1Path: string, part2Path: string, outputPath
|
|||||||
'-map', '[a]',
|
'-map', '[a]',
|
||||||
'-c:v', 'libx264',
|
'-c:v', 'libx264',
|
||||||
'-preset', 'fast',
|
'-preset', 'fast',
|
||||||
'-crf', '18',
|
'-crf', '17',
|
||||||
'-pix_fmt', 'yuv420p',
|
'-pix_fmt', 'yuv420p',
|
||||||
'-c:a', 'aac',
|
'-c:a', 'aac',
|
||||||
'-movflags', '+faststart',
|
'-movflags', '+faststart',
|
||||||
@@ -126,7 +252,7 @@ export async function concatMp4(part1Path: string, part2Path: string, outputPath
|
|||||||
'-map', '[v]',
|
'-map', '[v]',
|
||||||
'-c:v', 'libx264',
|
'-c:v', 'libx264',
|
||||||
'-preset', 'fast',
|
'-preset', 'fast',
|
||||||
'-crf', '18',
|
'-crf', '17',
|
||||||
'-pix_fmt', 'yuv420p',
|
'-pix_fmt', 'yuv420p',
|
||||||
'-movflags', '+faststart',
|
'-movflags', '+faststart',
|
||||||
outputPath
|
outputPath
|
||||||
@@ -145,11 +271,24 @@ export async function concatMp4(part1Path: string, part2Path: string, outputPath
|
|||||||
|
|
||||||
export async function stitchExtension(params: { part1Path: string; part2: Buffer; tmpDir: string }) {
|
export async function stitchExtension(params: { part1Path: string; part2: Buffer; tmpDir: string }) {
|
||||||
const part2Path = join(params.tmpDir, 'part2.mp4')
|
const part2Path = join(params.tmpDir, 'part2.mp4')
|
||||||
|
const trimmedPath = join(params.tmpDir, 'part2_trim.mp4')
|
||||||
const finalPath = join(params.tmpDir, 'final_stitched_video.mp4')
|
const finalPath = join(params.tmpDir, 'final_stitched_video.mp4')
|
||||||
writeFileSync(part2Path, params.part2)
|
writeFileSync(part2Path, params.part2)
|
||||||
await concatMp4(params.part1Path, part2Path, finalPath)
|
try {
|
||||||
|
await trimLeadingFrame(part2Path, trimmedPath)
|
||||||
|
await xfadeStitch(params.part1Path, trimmedPath, finalPath)
|
||||||
|
} catch (blendError) {
|
||||||
|
try {
|
||||||
|
const trimmedReady = existsSync(trimmedPath)
|
||||||
|
await concatMp4(params.part1Path, trimmedReady ? trimmedPath : part2Path, finalPath)
|
||||||
|
} catch (concatError) {
|
||||||
|
const blendMessage = blendError instanceof Error ? blendError.message : String(blendError)
|
||||||
|
const concatMessage = concatError instanceof Error ? concatError.message : String(concatError)
|
||||||
|
throw new Error(`Seam blend failed (${blendMessage}). Concat fallback also failed (${concatMessage}).`)
|
||||||
|
}
|
||||||
|
}
|
||||||
const stitched = readFileSync(finalPath)
|
const stitched = readFileSync(finalPath)
|
||||||
for (const extra of [part2Path, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) {
|
for (const extra of [part2Path, trimmedPath, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) {
|
||||||
try { unlinkSync(extra) } catch { /* ignore */ }
|
try { unlinkSync(extra) } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
return stitched
|
return stitched
|
||||||
|
|||||||
+20
-1
@@ -20,6 +20,9 @@ export interface JobEvent {
|
|||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
jobId?: string
|
jobId?: string
|
||||||
draftId?: string
|
draftId?: string
|
||||||
|
chainStep?: number
|
||||||
|
chainTotal?: number
|
||||||
|
chainLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
@@ -61,12 +64,18 @@ export interface Job {
|
|||||||
duration?: number
|
duration?: number
|
||||||
sound?: boolean
|
sound?: boolean
|
||||||
draftId?: string
|
draftId?: string
|
||||||
|
extensions?: { prompt: string; duration: number }[]
|
||||||
|
chainIndex?: number
|
||||||
|
chainStep?: number
|
||||||
|
chainTotal?: number
|
||||||
|
chainLabel?: string
|
||||||
extendTmpDir?: string
|
extendTmpDir?: string
|
||||||
extendPart1Path?: string
|
extendPart1Path?: string
|
||||||
extendSourceClipId?: string
|
extendSourceClipId?: string
|
||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
socketReady?: boolean
|
socketReady?: boolean
|
||||||
|
segmentBuffer?: Buffer
|
||||||
events: JobEvent[]
|
events: JobEvent[]
|
||||||
listeners: Set<(event: JobEvent) => void>
|
listeners: Set<(event: JobEvent) => void>
|
||||||
}
|
}
|
||||||
@@ -121,7 +130,10 @@ export function jobSnapshot(job: Job) {
|
|||||||
hideThumbnail: job.hideThumbnail,
|
hideThumbnail: job.hideThumbnail,
|
||||||
error: job.error,
|
error: job.error,
|
||||||
folderLocked: job.library?.folderLocked,
|
folderLocked: job.library?.folderLocked,
|
||||||
draftId: job.library?.draftId
|
draftId: job.library?.draftId,
|
||||||
|
chainStep: job.library?.chainStep,
|
||||||
|
chainTotal: job.library?.chainTotal,
|
||||||
|
chainLabel: job.library?.chainLabel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +143,13 @@ export function emitJob(job: Job, event: JobEvent) {
|
|||||||
if (typeof event.progress === 'number') job.progress = event.progress
|
if (typeof event.progress === 'number') job.progress = event.progress
|
||||||
if (typeof event.step === 'number') job.step = event.step
|
if (typeof event.step === 'number') job.step = event.step
|
||||||
if (typeof event.maxStep === 'number') job.maxStep = event.maxStep
|
if (typeof event.maxStep === 'number') job.maxStep = event.maxStep
|
||||||
|
if (typeof event.chainStep === 'number') {
|
||||||
|
if (job.library) job.library.chainStep = event.chainStep
|
||||||
|
}
|
||||||
|
if (typeof event.chainTotal === 'number') {
|
||||||
|
if (job.library) job.library.chainTotal = event.chainTotal
|
||||||
|
}
|
||||||
|
if (event.chainLabel && job.library) job.library.chainLabel = event.chainLabel
|
||||||
event.elapsedMs = Date.now() - job.startedAt
|
event.elapsedMs = Date.now() - job.startedAt
|
||||||
job.events.push(event)
|
job.events.push(event)
|
||||||
if (job.events.length > 200) job.events.shift()
|
if (job.events.length > 200) job.events.shift()
|
||||||
|
|||||||
+25
-3
@@ -57,6 +57,11 @@ export interface PublicFolder {
|
|||||||
clipCount: number
|
clipCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QueuedExtension {
|
||||||
|
prompt: string
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface RetryDraft {
|
export interface RetryDraft {
|
||||||
id: string
|
id: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
@@ -79,6 +84,7 @@ export interface RetryDraft {
|
|||||||
fps?: number
|
fps?: number
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
|
extensions?: QueuedExtension[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Catalog {
|
interface Catalog {
|
||||||
@@ -198,11 +204,15 @@ function ensureOwner(owner: string) {
|
|||||||
|
|
||||||
function readCatalog(owner: string): Catalog {
|
function readCatalog(owner: string): Catalog {
|
||||||
ensureOwner(owner)
|
ensureOwner(owner)
|
||||||
|
const path = catalogPath(owner)
|
||||||
try {
|
try {
|
||||||
return normalizeCatalog(JSON.parse(readFileSync(catalogPath(owner), 'utf8')))
|
return normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||||
} catch {
|
} catch {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'Library catalog could not be read' })
|
||||||
|
}
|
||||||
const created = emptyCatalog()
|
const created = emptyCatalog()
|
||||||
writeFileSync(catalogPath(owner), JSON.stringify(created, null, 2))
|
writeFileSync(path, JSON.stringify(created, null, 2))
|
||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,6 +243,16 @@ export function clipTitle(clip: Pick<LibraryClip, 'name' | 'prompt'>) {
|
|||||||
return fromPrompt || 'Untitled clip'
|
return fromPrompt || 'Untitled clip'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function nextClipPartName(name: string) {
|
||||||
|
const trimmed = (name || '').trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
const match = trimmed.match(/^(.*)\s+p(\d+)$/i)
|
||||||
|
const base = (match ? match[1] : trimmed).trim() || trimmed
|
||||||
|
const part = match ? Number(match[2]) + 1 : 2
|
||||||
|
const suffix = ` p${part}`
|
||||||
|
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
export function safeDownloadName(name: string) {
|
export function safeDownloadName(name: string) {
|
||||||
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
|
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
|
||||||
return cleaned.slice(0, 80) || 'aigen'
|
return cleaned.slice(0, 80) || 'aigen'
|
||||||
@@ -689,6 +709,7 @@ export async function saveRetryDraft(params: {
|
|||||||
fps?: number
|
fps?: number
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
|
extensions?: QueuedExtension[]
|
||||||
}) {
|
}) {
|
||||||
return mutate(params.ownerKey, (catalog) => {
|
return mutate(params.ownerKey, (catalog) => {
|
||||||
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
|
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
|
||||||
@@ -713,7 +734,8 @@ export async function saveRetryDraft(params: {
|
|||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
fps: params.fps,
|
fps: params.fps,
|
||||||
samplerName: params.samplerName,
|
samplerName: params.samplerName,
|
||||||
scheduler: params.scheduler
|
scheduler: params.scheduler,
|
||||||
|
extensions: params.extensions?.length ? params.extensions : undefined
|
||||||
}
|
}
|
||||||
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
|
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
|
||||||
return draft
|
return draft
|
||||||
|
|||||||
+97
-16
@@ -1,5 +1,5 @@
|
|||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import type { Job } from '~/server/utils/jobs'
|
import type { Job, JobEvent } from '~/server/utils/jobs'
|
||||||
|
|
||||||
function classifyError(message: string) {
|
function classifyError(message: string) {
|
||||||
const lower = message.toLowerCase()
|
const lower = message.toLowerCase()
|
||||||
@@ -19,13 +19,71 @@ function sleep(ms: number) {
|
|||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function watchComfyJob(job: Job): Promise<void> {
|
function chainMeta(job: Job) {
|
||||||
|
const total = Math.max(1, job.library?.chainTotal || 1)
|
||||||
|
const index = job.library?.chainIndex ?? 0
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
index,
|
||||||
|
step: index + 1,
|
||||||
|
label: job.library?.chainLabel || (index === 0 ? 'Initial' : `Extension ${index}`),
|
||||||
|
chained: total > 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapChainProgress(job: Job, localPct: number) {
|
||||||
|
const { total, index, chained } = chainMeta(job)
|
||||||
|
const local = Math.max(0, Math.min(100, localPct))
|
||||||
|
if (!chained) return Math.round(local)
|
||||||
|
const share = 100 / total
|
||||||
|
return Math.min(99, Math.round(index * share + (Math.min(local, 99) / 100) * share))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatChainMessage(job: Job, message: string, samplePct?: number) {
|
||||||
|
const { chained, step, total, label } = chainMeta(job)
|
||||||
|
if (!chained) return message
|
||||||
|
if (typeof samplePct === 'number') {
|
||||||
|
return `Step ${step}/${total}: Generating ${label} (${samplePct}%)`
|
||||||
|
}
|
||||||
|
if (/waiting 3s buffer/i.test(message)) {
|
||||||
|
return `Step ${step}/${total}: Waiting 3s buffer...`
|
||||||
|
}
|
||||||
|
return `Step ${step}/${total}: ${message}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitChainJob(job: Job, event: JobEvent, samplePct?: number) {
|
||||||
|
const { chained, step, total, label } = chainMeta(job)
|
||||||
|
const next: JobEvent = { ...event }
|
||||||
|
if (next.message) {
|
||||||
|
next.message = formatChainMessage(job, next.message, samplePct)
|
||||||
|
}
|
||||||
|
if (chained && typeof next.progress === 'number' && next.type !== 'complete') {
|
||||||
|
next.progress = mapChainProgress(job, next.progress)
|
||||||
|
}
|
||||||
|
if (chained) {
|
||||||
|
next.chainStep = step
|
||||||
|
next.chainTotal = total
|
||||||
|
next.chainLabel = label
|
||||||
|
}
|
||||||
|
emitJob(job, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Promise<void> {
|
||||||
|
const persist = options.persist !== false
|
||||||
|
job.socketReady = false
|
||||||
const ws = new WebSocket(comfyWsUrl(job.clientId))
|
const ws = new WebSocket(comfyWsUrl(job.clientId))
|
||||||
let settled = false
|
let settled = false
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
let localProgress = 0
|
||||||
|
|
||||||
|
const emitLocal = (event: JobEvent, samplePct?: number) => {
|
||||||
|
if (typeof event.progress === 'number') localProgress = event.progress
|
||||||
|
emitChainJob(job, event, samplePct)
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (timeout) clearTimeout(timeout)
|
if (timeout) clearTimeout(timeout)
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
@@ -40,7 +98,7 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
cleanup()
|
cleanup()
|
||||||
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
|
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
|
||||||
job.error = classifyError(error)
|
job.error = classifyError(error)
|
||||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
emitChainJob(job, { type: 'error', error: job.error, message: job.error })
|
||||||
if (job.id) deletePendingJob(job.id)
|
if (job.id) deletePendingJob(job.id)
|
||||||
removeExtendTemp(job.library?.extendTmpDir)
|
removeExtendTemp(job.library?.extendTmpDir)
|
||||||
resolve()
|
resolve()
|
||||||
@@ -56,9 +114,9 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
job.video = video
|
job.video = video
|
||||||
const stitching = Boolean(job.library?.extendPart1Path && job.library.extendTmpDir)
|
const stitching = Boolean(job.library?.extendPart1Path && job.library.extendTmpDir)
|
||||||
emitJob(job, {
|
emitLocal({
|
||||||
type: 'status',
|
type: 'status',
|
||||||
message: stitching ? 'Extracting last frame & stitching extension...' : 'Saving to library...',
|
message: stitching ? 'Extracting last frame & stitching extension...' : (persist ? 'Saving to library...' : 'Downloading segment...'),
|
||||||
progress: stitching ? 97 : 98
|
progress: stitching ? 97 : 98
|
||||||
})
|
})
|
||||||
if (job.library) {
|
if (job.library) {
|
||||||
@@ -73,6 +131,23 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
tmpDir: job.library.extendTmpDir!
|
tmpDir: job.library.extendTmpDir!
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (!persist) {
|
||||||
|
job.segmentBuffer = buffer
|
||||||
|
await purgeComfyArtifacts({
|
||||||
|
video,
|
||||||
|
imageName: job.library.imageName,
|
||||||
|
imageSubfolder: job.library.imageSubfolder,
|
||||||
|
promptId: job.promptId
|
||||||
|
})
|
||||||
|
deletePendingJob(job.id)
|
||||||
|
emitLocal({
|
||||||
|
type: 'status',
|
||||||
|
message: stitching ? 'Extension stitched' : 'Initial segment ready',
|
||||||
|
progress: 99
|
||||||
|
})
|
||||||
|
resolve()
|
||||||
|
return true
|
||||||
|
}
|
||||||
const clip = await saveClip({
|
const clip = await saveClip({
|
||||||
ownerKey: job.library.ownerKey,
|
ownerKey: job.library.ownerKey,
|
||||||
folderId: job.library.folderId,
|
folderId: job.library.folderId,
|
||||||
@@ -111,22 +186,27 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
type: 'complete',
|
type: 'complete',
|
||||||
message: job.library?.folderLocked
|
message: job.library?.folderLocked
|
||||||
? 'Saved to the locked folder. Unlock it to view.'
|
? 'Saved to the locked folder. Unlock it to view.'
|
||||||
: (job.library?.extendSourceClipId ? 'Extended video ready' : 'Video ready'),
|
: (job.library?.extendSourceClipId || (job.library?.chainTotal || 1) > 1 ? 'Extended video ready' : 'Video ready'),
|
||||||
progress: 100,
|
progress: 100,
|
||||||
filename: job.library?.folderLocked ? undefined : video.filename,
|
filename: job.library?.folderLocked ? undefined : video.filename,
|
||||||
subfolder: job.library?.folderLocked ? undefined : video.subfolder,
|
subfolder: job.library?.folderLocked ? undefined : video.subfolder,
|
||||||
mediaType: job.library?.folderLocked ? undefined : video.type,
|
mediaType: job.library?.folderLocked ? undefined : video.type,
|
||||||
clipId: job.clipId,
|
clipId: job.clipId,
|
||||||
hideThumbnail: job.hideThumbnail,
|
hideThumbnail: job.hideThumbnail,
|
||||||
folderLocked: job.library?.folderLocked
|
folderLocked: job.library?.folderLocked,
|
||||||
|
chainStep: job.library?.chainStep,
|
||||||
|
chainTotal: job.library?.chainTotal,
|
||||||
|
chainLabel: job.library?.chainLabel
|
||||||
})
|
})
|
||||||
deletePendingJob(job.id)
|
deletePendingJob(job.id)
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
removeExtendTemp(job.library?.extendTmpDir)
|
removeExtendTemp(job.library?.extendTmpDir)
|
||||||
const message = saveError instanceof Error ? saveError.message : String(saveError)
|
const message = saveError instanceof Error ? saveError.message : String(saveError)
|
||||||
job.status = 'error'
|
job.status = 'error'
|
||||||
job.error = `Video generated but library save failed: ${message}`
|
job.error = persist
|
||||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
? `Video generated but library save failed: ${message}`
|
||||||
|
: `Segment finished but could not be prepared: ${message}`
|
||||||
|
emitChainJob(job, { type: 'error', error: job.error, message: job.error })
|
||||||
}
|
}
|
||||||
resolve()
|
resolve()
|
||||||
return true
|
return true
|
||||||
@@ -169,7 +249,7 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
|
|
||||||
ws.addEventListener('open', () => {
|
ws.addEventListener('open', () => {
|
||||||
job.socketReady = true
|
job.socketReady = true
|
||||||
emitJob(job, { type: 'status', message: 'Connected to ComfyUI', progress: Math.max(job.progress, 4) })
|
emitLocal({ type: 'status', message: 'Connected to ComfyUI', progress: Math.max(localProgress, 4) })
|
||||||
})
|
})
|
||||||
|
|
||||||
ws.addEventListener('close', () => {
|
ws.addEventListener('close', () => {
|
||||||
@@ -191,15 +271,16 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
const value = Number(data.value || 0)
|
const value = Number(data.value || 0)
|
||||||
const max = Number(data.max || 1)
|
const max = Number(data.max || 1)
|
||||||
const pct = 12 + Math.round((value / Math.max(max, 1)) * 70)
|
const pct = 12 + Math.round((value / Math.max(max, 1)) * 70)
|
||||||
|
const samplePct = Math.round((value / Math.max(max, 1)) * 100)
|
||||||
job.status = 'running'
|
job.status = 'running'
|
||||||
emitJob(job, {
|
emitLocal({
|
||||||
type: 'progress',
|
type: 'progress',
|
||||||
step: value,
|
step: value,
|
||||||
maxStep: max,
|
maxStep: max,
|
||||||
progress: pct,
|
progress: pct,
|
||||||
node: data.node ? String(data.node) : null,
|
node: data.node ? String(data.node) : null,
|
||||||
message: `Sampling step ${value}/${max} (${Math.round((value / Math.max(max, 1)) * 100)}%)`
|
message: `Sampling step ${value}/${max} (${samplePct}%)`
|
||||||
})
|
}, samplePct)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === 'executing') {
|
if (payload.type === 'executing') {
|
||||||
@@ -207,16 +288,16 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
if (node === null) {
|
if (node === null) {
|
||||||
const saved = await succeed()
|
const saved = await succeed()
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
emitJob(job, { type: 'status', message: 'Waiting for ComfyUI to save the MP4...', progress: 96 })
|
emitLocal({ type: 'status', message: 'Waiting for ComfyUI to save the MP4...', progress: 96 })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const label = NODE_LABELS[node] || `Running node ${node}`
|
const label = NODE_LABELS[node] || `Running node ${node}`
|
||||||
const encoding = isEncodingNode(node)
|
const encoding = isEncodingNode(node)
|
||||||
emitJob(job, {
|
emitLocal({
|
||||||
type: 'executing',
|
type: 'executing',
|
||||||
node,
|
node,
|
||||||
progress: encoding ? 90 : Math.max(job.progress, 10),
|
progress: encoding ? 90 : Math.max(localProgress, 10),
|
||||||
message: encoding ? 'Encoding video...' : `${label}...`
|
message: encoding ? 'Encoding video...' : `${label}...`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user