Keep pause across Coolify restarts, apply the chosen still from the image picker, and remux clips with Range/faststart so finished video can be scrubbed.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -90,7 +90,8 @@
|
|||||||
<article
|
<article
|
||||||
v-for="still in visibleStills"
|
v-for="still in visibleStills"
|
||||||
:key="still.id"
|
:key="still.id"
|
||||||
class="library-thumb overflow-hidden rounded-2xl border border-white/10 bg-zinc-950"
|
class="library-thumb cursor-pointer overflow-hidden rounded-2xl border border-white/10 bg-zinc-950"
|
||||||
|
@click="pickStill(still)"
|
||||||
>
|
>
|
||||||
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
||||||
<img
|
<img
|
||||||
@@ -108,7 +109,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900"
|
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900"
|
||||||
:title="isRevealed(still.id) ? 'Hide thumbnail' : 'Reveal thumbnail'"
|
:title="isRevealed(still.id) ? 'Hide thumbnail' : 'Reveal thumbnail'"
|
||||||
@click="toggleReveal(still.id)"
|
@click.stop="toggleReveal(still.id)"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
||||||
<template v-if="isRevealed(still.id)">
|
<template v-if="isRevealed(still.id)">
|
||||||
@@ -125,7 +126,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
|
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
|
||||||
title="Use as input"
|
title="Use as input"
|
||||||
@click="pickStill(still)"
|
@click.stop="pickStill(still)"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="h-4 w-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="h-4 w-4">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
@@ -135,7 +136,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
||||||
<span class="truncate text-xs text-zinc-400">{{ still.name || still.filename }}</span>
|
<span class="truncate text-xs text-zinc-400">{{ still.name || still.filename }}</span>
|
||||||
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click="removeStill(still)">Delete</button>
|
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click.stop="removeStill(still)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,6 +191,7 @@ const activeFolderId = ref(props.folderId)
|
|||||||
const tab = ref<'input' | 'output'>('input')
|
const tab = ref<'input' | 'output'>('input')
|
||||||
const revealAll = ref(false)
|
const revealAll = ref(false)
|
||||||
const revealedIds = ref<string[]>([])
|
const revealedIds = ref<string[]>([])
|
||||||
|
const picking = ref(false)
|
||||||
|
|
||||||
function onKey(event: KeyboardEvent) {
|
function onKey(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') emit('close')
|
if (event.key === 'Escape') emit('close')
|
||||||
@@ -266,7 +268,10 @@ async function ingest(file: File) {
|
|||||||
try {
|
try {
|
||||||
const result = await $fetch<{ still?: { id: string; filename: string; hideInput?: boolean } }>('/api/library/stills', { method: 'POST', body })
|
const result = await $fetch<{ still?: { id: string; filename: string; hideInput?: boolean } }>('/api/library/stills', { method: 'POST', body })
|
||||||
emit('updated', result)
|
emit('updated', result)
|
||||||
|
if (props.stillOnly) {
|
||||||
if (result.still?.id) emit('pick', result.still)
|
if (result.still?.id) emit('pick', result.still)
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('select', file, props.hideInputPreview === true, result.still?.id)
|
emit('select', file, props.hideInputPreview === true, result.still?.id)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
emit('error', error?.data?.statusMessage || 'Could not save image')
|
emit('error', error?.data?.statusMessage || 'Could not save image')
|
||||||
@@ -287,14 +292,20 @@ function onDrop(event: DragEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pickStill(still: PickerStill) {
|
async function pickStill(still: PickerStill) {
|
||||||
emit('pick', { id: still.id, filename: still.filename, hideInput: still.hideInput })
|
if (picking.value) return
|
||||||
if (props.stillOnly) return
|
picking.value = true
|
||||||
try {
|
try {
|
||||||
|
if (props.stillOnly) {
|
||||||
|
emit('pick', { id: still.id, filename: still.filename, hideInput: still.hideInput })
|
||||||
|
return
|
||||||
|
}
|
||||||
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
||||||
const file = new File([blob], still.filename, { type: blob.type || 'image/png' })
|
const file = new File([blob], still.filename, { type: blob.type || 'image/png' })
|
||||||
emit('select', file, still.hideInput === true, still.id)
|
emit('select', file, still.hideInput === true, still.id)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
||||||
|
} finally {
|
||||||
|
picking.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -874,7 +874,10 @@
|
|||||||
:autoplay="autoplayEnabled && !awaitingReveal"
|
:autoplay="autoplayEnabled && !awaitingReveal"
|
||||||
loop
|
loop
|
||||||
playsinline
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
@play="onPlayed"
|
@play="onPlayed"
|
||||||
|
@loadedmetadata="onVideoMeta"
|
||||||
|
@durationchange="onVideoMeta"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="awaitingReveal && !lockedSave"
|
v-if="awaitingReveal && !lockedSave"
|
||||||
@@ -899,6 +902,12 @@
|
|||||||
Generated MP4 appears here
|
Generated MP4 appears here
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="videoUrl && !lockedSave && !videoSeekable" class="mt-3 text-xs text-amber-200/80">
|
||||||
|
Encoding… this file cannot be scrubbed until Comfy finishes writing it.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="playingLastSavedWhileBusy" class="mt-3 text-xs text-zinc-500">
|
||||||
|
Playing the last saved part. The shot still encoding cannot be scrubbed until it saves.
|
||||||
|
</p>
|
||||||
<div v-if="videoUrl && !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"
|
v-if="!awaitingReveal"
|
||||||
@@ -1648,6 +1657,7 @@ const activeChainPlan = ref<{ label: string; prompt: string; duration: number }[
|
|||||||
const expandedFamilies = ref<Record<string, boolean>>({})
|
const expandedFamilies = ref<Record<string, boolean>>({})
|
||||||
const elapsedMs = ref(0)
|
const elapsedMs = ref(0)
|
||||||
const videoUrl = ref('')
|
const videoUrl = ref('')
|
||||||
|
const videoSeekable = ref(true)
|
||||||
const editResultUrl = ref('')
|
const editResultUrl = ref('')
|
||||||
const editProgress = ref(0)
|
const editProgress = ref(0)
|
||||||
const editStatusMessage = ref('Waiting for a still.')
|
const editStatusMessage = ref('Waiting for a still.')
|
||||||
@@ -1800,6 +1810,9 @@ const outputPreviewKind = computed<'video' | 'edit'>(() => {
|
|||||||
const liveElapsedLabel = computed(() => (
|
const liveElapsedLabel = computed(() => (
|
||||||
outputProgressKind.value === 'edit' ? editElapsedLabel.value : elapsedLabel.value
|
outputProgressKind.value === 'edit' ? editElapsedLabel.value : elapsedLabel.value
|
||||||
))
|
))
|
||||||
|
const playingLastSavedWhileBusy = computed(() => (
|
||||||
|
videoBusy.value && Boolean(videoUrl.value) && !watchingLibrary.value && !lockedSave.value
|
||||||
|
))
|
||||||
const liveStatusMessage = computed(() => (
|
const liveStatusMessage = computed(() => (
|
||||||
outputProgressKind.value === 'edit' ? editStatusMessage.value : statusMessage.value
|
outputProgressKind.value === 'edit' ? editStatusMessage.value : statusMessage.value
|
||||||
))
|
))
|
||||||
@@ -2481,6 +2494,10 @@ watch(withSound, (value) => {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(videoUrl, () => {
|
||||||
|
videoSeekable.value = true
|
||||||
|
})
|
||||||
|
|
||||||
watch(autoplayEnabled, (value) => {
|
watch(autoplayEnabled, (value) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('aigen-autoplay', String(value))
|
localStorage.setItem('aigen-autoplay', String(value))
|
||||||
@@ -2809,6 +2826,7 @@ function updateClipRefLabel(clip: LibraryClip, index: number, label: string) {
|
|||||||
|
|
||||||
function onPickedPermanence(still: { id: string }) {
|
function onPickedPermanence(still: { id: string }) {
|
||||||
const slot = pickerSlot.value
|
const slot = pickerSlot.value
|
||||||
|
if (slot === 'main' || slot === 'editRef' || typeof slot === 'number') return
|
||||||
pickerOpen.value = false
|
pickerOpen.value = false
|
||||||
if (typeof slot === 'string' && slot.startsWith('clip:')) {
|
if (typeof slot === 'string' && slot.startsWith('clip:')) {
|
||||||
const clipId = slot.slice(5)
|
const clipId = slot.slice(5)
|
||||||
@@ -3883,6 +3901,16 @@ function onPlayed() {
|
|||||||
awaitingReveal.value = false
|
awaitingReveal.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onVideoMeta() {
|
||||||
|
const el = player.value
|
||||||
|
if (!el) {
|
||||||
|
videoSeekable.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const duration = el.duration
|
||||||
|
videoSeekable.value = Number.isFinite(duration) && duration > 0
|
||||||
|
}
|
||||||
|
|
||||||
function revealAndPlay() {
|
function revealAndPlay() {
|
||||||
awaitingReveal.value = false
|
awaitingReveal.value = false
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
|
import { sendPathWithRange } from '~/server/utils/httpRange'
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
@@ -9,8 +10,8 @@ export default defineEventHandler((event) => {
|
|||||||
if (!existsSync(path)) {
|
if (!existsSync(path)) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Video file is missing' })
|
throw createError({ statusCode: 404, statusMessage: 'Video file is missing' })
|
||||||
}
|
}
|
||||||
setHeader(event, 'Content-Type', 'video/mp4')
|
return sendPathWithRange(event, path, 'video/mp4', {
|
||||||
setHeader(event, 'Content-Disposition', `inline; filename="${safeDownloadName(clipTitle(clip))}.mp4"`)
|
'Content-Disposition': `inline; filename="${safeDownloadName(clipTitle(clip))}.mp4"`,
|
||||||
setHeader(event, 'Cache-Control', 'private, no-store')
|
'Cache-Control': 'private, max-age=0, must-revalidate'
|
||||||
return readFileSync(path)
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { sendBufferWithRange } from '~/server/utils/httpRange'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const query = getQuery(event)
|
const query = getQuery(event)
|
||||||
const filename = String(query.filename || '')
|
const filename = String(query.filename || '')
|
||||||
@@ -12,8 +14,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch video from ComfyUI' })
|
throw createError({ statusCode: res.status, statusMessage: 'Failed to fetch video from ComfyUI' })
|
||||||
}
|
}
|
||||||
const buf = Buffer.from(await res.arrayBuffer())
|
const buf = Buffer.from(await res.arrayBuffer())
|
||||||
setHeader(event, 'Content-Type', res.headers.get('content-type') || 'video/mp4')
|
return sendBufferWithRange(event, buf, res.headers.get('content-type') || 'video/mp4', {
|
||||||
setHeader(event, 'Content-Disposition', `inline; filename="${filename}"`)
|
'Content-Disposition': `inline; filename="${filename}"`,
|
||||||
setHeader(event, 'Cache-Control', 'private, max-age=3600')
|
'Cache-Control': 'private, max-age=0, must-revalidate'
|
||||||
return buf
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { listPendingJobs } from '~/server/utils/pending'
|
import { listPendingJobs } from '~/server/utils/pending'
|
||||||
import { listOwnersWithQueues, listShotQueues, pendingSegmentCount } from '~/server/utils/shotQueue'
|
import { listOwnersWithQueues, listShotQueues, pendingSegmentCount } from '~/server/utils/shotQueue'
|
||||||
import { kickStudioQueue, listOwnersWithStudioQueues, listStudioJobs } from '~/server/utils/studioQueue'
|
import { isStudioQueuePaused, kickStudioQueue, listOwnersWithStudioQueues, listStudioJobs } from '~/server/utils/studioQueue'
|
||||||
import { startQueueBurst } from '~/server/utils/videoChain'
|
import { startQueueBurst } from '~/server/utils/videoChain'
|
||||||
|
|
||||||
export default defineNitroPlugin(() => {
|
export default defineNitroPlugin(() => {
|
||||||
@@ -15,11 +15,12 @@ export default defineNitroPlugin(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const owner of listOwnersWithQueues()) {
|
for (const owner of listOwnersWithQueues()) {
|
||||||
|
if (isStudioQueuePaused(owner)) continue
|
||||||
for (const queue of listShotQueues(owner)) {
|
for (const queue of listShotQueues(owner)) {
|
||||||
if (claimedQueueIds.has(queue.id)) continue
|
if (claimedQueueIds.has(queue.id)) continue
|
||||||
if (!queue.autoRun) continue
|
if (!queue.autoRun) continue
|
||||||
if (queue.stopAfterCurrent) continue
|
if (queue.stopAfterCurrent) continue
|
||||||
if (queue.status === 'complete') continue
|
if (queue.status === 'paused' || queue.status === 'complete' || queue.status === 'error') continue
|
||||||
if (pendingQueueIds.has(queue.id)) continue
|
if (pendingQueueIds.has(queue.id)) continue
|
||||||
if (!queue.currentClipId || pendingSegmentCount(queue) === 0) continue
|
if (!queue.currentClipId || pendingSegmentCount(queue) === 0) continue
|
||||||
void startQueueBurst(owner, queue.id, 'all').catch(() => null)
|
void startQueueBurst(owner, queue.id, 'all').catch(() => null)
|
||||||
|
|||||||
+54
-2
@@ -1,4 +1,4 @@
|
|||||||
import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
|
import { existsSync, writeFileSync, readFileSync, unlinkSync, statSync, openSync, readSync, closeSync, copyFileSync } from 'node:fs'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
|
|
||||||
@@ -171,6 +171,58 @@ async function probeSize(filePath: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mp4MoovIsAtStart(filePath: string) {
|
||||||
|
const fd = openSync(filePath, 'r')
|
||||||
|
try {
|
||||||
|
let offset = 0
|
||||||
|
const header = Buffer.alloc(16)
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
if (readSync(fd, header, 0, 8, offset) < 8) return false
|
||||||
|
let size = header.readUInt32BE(0)
|
||||||
|
const type = header.toString('ascii', 4, 8)
|
||||||
|
if (size === 1) {
|
||||||
|
if (readSync(fd, header, 8, 8, offset + 8) < 8) return false
|
||||||
|
size = Number(header.readBigUInt64BE(8))
|
||||||
|
}
|
||||||
|
if (!size || size < 8) return false
|
||||||
|
if (type === 'moov') return true
|
||||||
|
if (type === 'mdat') return false
|
||||||
|
offset += size
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
closeSync(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remuxFaststart(inputPath: string) {
|
||||||
|
if (!existsSync(inputPath)) return
|
||||||
|
try {
|
||||||
|
if (mp4MoovIsAtStart(inputPath)) return
|
||||||
|
} catch {
|
||||||
|
// Fall through and try a remux anyway.
|
||||||
|
}
|
||||||
|
const tmp = `${inputPath}.faststart.tmp.mp4`
|
||||||
|
try {
|
||||||
|
await runFfmpeg([
|
||||||
|
'-y',
|
||||||
|
'-i', inputPath,
|
||||||
|
'-c', 'copy',
|
||||||
|
'-map', '0',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
tmp
|
||||||
|
], 120000)
|
||||||
|
if (!existsSync(tmp) || statSync(tmp).size < 32) {
|
||||||
|
try { unlinkSync(tmp) } catch { /* ignore */ }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copyFileSync(tmp, inputPath)
|
||||||
|
try { unlinkSync(tmp) } catch { /* ignore */ }
|
||||||
|
} catch {
|
||||||
|
try { unlinkSync(tmp) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function encodeArgs(outputPath: string, withAudio: boolean) {
|
function encodeArgs(outputPath: string, withAudio: boolean) {
|
||||||
const args = [
|
const args = [
|
||||||
'-c:v', 'libx264',
|
'-c:v', 'libx264',
|
||||||
@@ -271,7 +323,7 @@ export async function concatMp4(part1Path: string, part2Path: string, outputPath
|
|||||||
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`)
|
||||||
try {
|
try {
|
||||||
await runFfmpeg(['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath], 180000)
|
await runFfmpeg(['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', '-movflags', '+faststart', outputPath], 180000)
|
||||||
await assertStitchLength(part1Path, part2Path, outputPath)
|
await assertStitchLength(part1Path, part2Path, outputPath)
|
||||||
} catch {
|
} catch {
|
||||||
await concatReencode(part1Path, part2Path, outputPath)
|
await concatReencode(part1Path, part2Path, outputPath)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { createReadStream, statSync } from 'node:fs'
|
||||||
|
import { createError, getHeader, sendStream, setHeader, setResponseStatus, type H3Event } from 'h3'
|
||||||
|
|
||||||
|
function parseBytesRange(header: string | undefined, size: number) {
|
||||||
|
if (!header) return null
|
||||||
|
const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim())
|
||||||
|
if (!match) return null
|
||||||
|
const startToken = match[1]
|
||||||
|
const endToken = match[2]
|
||||||
|
if (!startToken && !endToken) return { unsatisfiable: true as const, start: 0, end: 0 }
|
||||||
|
let start: number
|
||||||
|
let end: number
|
||||||
|
if (!startToken) {
|
||||||
|
const suffix = Number(endToken)
|
||||||
|
if (!Number.isFinite(suffix) || suffix <= 0) return { unsatisfiable: true as const, start: 0, end: 0 }
|
||||||
|
start = Math.max(0, size - suffix)
|
||||||
|
end = size - 1
|
||||||
|
} else {
|
||||||
|
start = Number(startToken)
|
||||||
|
end = endToken ? Number(endToken) : size - 1
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size || end < start) {
|
||||||
|
return { unsatisfiable: true as const, start: 0, end: 0 }
|
||||||
|
}
|
||||||
|
return { start, end: Math.min(end, size - 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRangeHeaders(event: H3Event, contentType: string, extraHeaders?: Record<string, string>) {
|
||||||
|
setHeader(event, 'Accept-Ranges', 'bytes')
|
||||||
|
setHeader(event, 'Content-Type', contentType)
|
||||||
|
for (const [key, value] of Object.entries(extraHeaders || {})) {
|
||||||
|
setHeader(event, key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectUnsatisfiable(event: H3Event, size: number) {
|
||||||
|
setHeader(event, 'Content-Range', `bytes */${size}`)
|
||||||
|
throw createError({ statusCode: 416, statusMessage: 'Range Not Satisfiable' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPathWithRange(event: H3Event, path: string, contentType: string, extraHeaders?: Record<string, string>) {
|
||||||
|
const size = statSync(path).size
|
||||||
|
applyRangeHeaders(event, contentType, extraHeaders)
|
||||||
|
const range = parseBytesRange(getHeader(event, 'range'), size)
|
||||||
|
if (!range) {
|
||||||
|
setHeader(event, 'Content-Length', String(size))
|
||||||
|
return sendStream(event, createReadStream(path))
|
||||||
|
}
|
||||||
|
if ('unsatisfiable' in range && range.unsatisfiable) rejectUnsatisfiable(event, size)
|
||||||
|
setResponseStatus(event, 206)
|
||||||
|
setHeader(event, 'Content-Range', `bytes ${range.start}-${range.end}/${size}`)
|
||||||
|
setHeader(event, 'Content-Length', String(range.end - range.start + 1))
|
||||||
|
return sendStream(event, createReadStream(path, { start: range.start, end: range.end }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendBufferWithRange(event: H3Event, buf: Buffer, contentType: string, extraHeaders?: Record<string, string>) {
|
||||||
|
const size = buf.length
|
||||||
|
applyRangeHeaders(event, contentType, extraHeaders)
|
||||||
|
const range = parseBytesRange(getHeader(event, 'range'), size)
|
||||||
|
if (!range) {
|
||||||
|
setHeader(event, 'Content-Length', String(size))
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
if ('unsatisfiable' in range && range.unsatisfiable) rejectUnsatisfiable(event, size)
|
||||||
|
setResponseStatus(event, 206)
|
||||||
|
setHeader(event, 'Content-Range', `bytes ${range.start}-${range.end}/${size}`)
|
||||||
|
setHeader(event, 'Content-Length', String(range.end - range.start + 1))
|
||||||
|
return buf.subarray(range.start, range.end + 1)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process'
|
|||||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
import { extractFirstFrame, extractLastFrame } from '~/server/utils/ffmpeg'
|
import { extractFirstFrame, extractLastFrame, remuxFaststart } from '~/server/utils/ffmpeg'
|
||||||
import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
|
import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
const scryptAsync = promisify(scrypt)
|
const scryptAsync = promisify(scrypt)
|
||||||
@@ -1348,6 +1348,7 @@ export async function saveClip(params: {
|
|||||||
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
||||||
const videoPath = clipVideoPath(params.ownerKey, clip.id)
|
const videoPath = clipVideoPath(params.ownerKey, clip.id)
|
||||||
writeFileSync(videoPath, params.video)
|
writeFileSync(videoPath, params.video)
|
||||||
|
await remuxFaststart(videoPath)
|
||||||
if (typeof params.duration === 'number' && params.duration > 0) {
|
if (typeof params.duration === 'number' && params.duration > 0) {
|
||||||
clip.duration = params.duration
|
clip.duration = params.duration
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface PendingJob {
|
|||||||
queueId?: string
|
queueId?: string
|
||||||
queueAutoRun?: boolean
|
queueAutoRun?: boolean
|
||||||
queueBudget?: number
|
queueBudget?: number
|
||||||
|
stopAfterCurrent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingRoot() {
|
function pendingRoot() {
|
||||||
@@ -140,6 +141,7 @@ export function pendingFromJob(job: Job, overrides: Partial<PendingJob> = {}): P
|
|||||||
queueId: library.queueId,
|
queueId: library.queueId,
|
||||||
queueAutoRun: library.queueAutoRun,
|
queueAutoRun: library.queueAutoRun,
|
||||||
queueBudget: library.queueBudget,
|
queueBudget: library.queueBudget,
|
||||||
|
stopAfterCurrent: library.stopAfterCurrent,
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,6 +187,7 @@ export function libraryFromPending(pending: PendingJob): NonNullable<Job['librar
|
|||||||
queueId: pending.queueId,
|
queueId: pending.queueId,
|
||||||
queueAutoRun: pending.queueAutoRun,
|
queueAutoRun: pending.queueAutoRun,
|
||||||
queueBudget: pending.queueBudget,
|
queueBudget: pending.queueBudget,
|
||||||
|
stopAfterCurrent: pending.stopAfterCurrent,
|
||||||
globalLocks: pending.globalLocks,
|
globalLocks: pending.globalLocks,
|
||||||
permanenceRefs: pending.permanenceRefs,
|
permanenceRefs: pending.permanenceRefs,
|
||||||
shotPermanenceRefs: pending.shotPermanenceRefs
|
shotPermanenceRefs: pending.shotPermanenceRefs
|
||||||
@@ -198,6 +201,14 @@ export function writePendingJob(job: PendingJob) {
|
|||||||
renameSync(tmp, pendingPath(job.jobId))
|
renameSync(tmp, pendingPath(job.jobId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function patchPendingJob(jobId: string, patch: Partial<PendingJob>) {
|
||||||
|
const existing = readPendingJob(jobId)
|
||||||
|
if (!existing) return null
|
||||||
|
const next = { ...existing, ...patch }
|
||||||
|
writePendingJob(next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
export function readPendingJob(jobId: string): PendingJob | null {
|
export function readPendingJob(jobId: string): PendingJob | null {
|
||||||
const path = pendingPath(jobId)
|
const path = pendingPath(jobId)
|
||||||
if (!existsSync(path)) return null
|
if (!existsSync(path)) return null
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { getJob, listJobs, type Job } from '~/server/utils/jobs'
|
import { getJob, listJobs, type Job } from '~/server/utils/jobs'
|
||||||
|
import { listPendingJobs, patchPendingJob, readPendingJob } from '~/server/utils/pending'
|
||||||
|
import { getShotQueue } from '~/server/utils/shotQueue'
|
||||||
import { parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
|
import { parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
|
||||||
import type { PermanenceRef } from '~/utils/globalLocks'
|
import type { PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
@@ -181,7 +183,29 @@ export function summarizeStudioJob(job: StudioJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function videoJobsBusy() {
|
export function videoJobsBusy() {
|
||||||
return listJobs().some(job => job.kind !== 'edit' && (job.status === 'queued' || job.status === 'uploading' || job.status === 'running'))
|
if (listJobs().some(job => job.kind !== 'edit' && (job.status === 'queued' || job.status === 'uploading' || job.status === 'running'))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return listPendingJobs().some(job => Boolean(job.promptId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPersistedPauseToJob(job: Job) {
|
||||||
|
const library = job.library
|
||||||
|
if (!library) return false
|
||||||
|
const store = readStore(library.ownerKey)
|
||||||
|
const row = store.jobs.find(item => item.liveJobId === job.id)
|
||||||
|
|| (library.queueId ? store.jobs.find(item => item.shotQueueId === library.queueId) : undefined)
|
||||||
|
const queue = library.queueId ? getShotQueue(library.ownerKey, library.queueId) : null
|
||||||
|
const paused = store.paused === true
|
||||||
|
|| row?.pauseAfterCurrent === true
|
||||||
|
|| row?.pausedByUser === true
|
||||||
|
|| queue?.stopAfterCurrent === true
|
||||||
|
|| queue?.status === 'paused'
|
||||||
|
if (paused) {
|
||||||
|
library.stopAfterCurrent = true
|
||||||
|
library.queueAutoRun = false
|
||||||
|
}
|
||||||
|
return paused
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addStudioJob(params: {
|
export async function addStudioJob(params: {
|
||||||
@@ -370,21 +394,42 @@ export function kickStudioQueue() {
|
|||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
function repairStaleJobs(jobs: StudioJob[]) {
|
function pendingAlive(job: StudioJob) {
|
||||||
|
if (job.liveJobId && readPendingJob(job.liveJobId)?.promptId) return true
|
||||||
|
if (!job.shotQueueId) return false
|
||||||
|
return listPendingJobs().some(pending => (
|
||||||
|
pending.ownerKey === job.ownerKey
|
||||||
|
&& pending.queueId === job.shotQueueId
|
||||||
|
&& Boolean(pending.promptId)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairStaleJobs(jobs: StudioJob[], paused: boolean) {
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
if (job.status !== 'running') continue
|
if (job.status !== 'running') continue
|
||||||
const live = job.liveJobId ? getJob(job.liveJobId) : undefined
|
const live = job.liveJobId ? getJob(job.liveJobId) : undefined
|
||||||
const liveBusy = live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')
|
const liveBusy = live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')
|
||||||
if (liveBusy) continue
|
if (liveBusy || pendingAlive(job)) continue
|
||||||
|
const holdPause = paused || job.pauseAfterCurrent === true || job.pausedByUser === true
|
||||||
if (job.shotQueueId) {
|
if (job.shotQueueId) {
|
||||||
job.status = 'held'
|
job.status = 'held'
|
||||||
job.liveJobId = undefined
|
job.liveJobId = undefined
|
||||||
|
if (holdPause) {
|
||||||
|
job.pausedByUser = true
|
||||||
|
job.pauseAfterCurrent = false
|
||||||
|
job.holdForCutIn = false
|
||||||
|
} else {
|
||||||
job.holdForCutIn = true
|
job.holdForCutIn = true
|
||||||
job.resumeAutoRun = job.payload.queueAutoRun === true || job.resumeAutoRun === true
|
job.resumeAutoRun = job.payload.queueAutoRun === true || job.resumeAutoRun === true
|
||||||
|
}
|
||||||
job.updatedAt = Date.now()
|
job.updatedAt = Date.now()
|
||||||
} else {
|
} else {
|
||||||
job.status = 'complete'
|
job.status = holdPause ? 'held' : 'complete'
|
||||||
job.liveJobId = undefined
|
job.liveJobId = undefined
|
||||||
|
if (holdPause) {
|
||||||
|
job.pausedByUser = true
|
||||||
|
job.pauseAfterCurrent = false
|
||||||
|
}
|
||||||
job.updatedAt = Date.now()
|
job.updatedAt = Date.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +439,7 @@ async function dispatchStudioQueue() {
|
|||||||
if (videoJobsBusy()) return
|
if (videoJobsBusy()) return
|
||||||
for (const owner of listOwnersWithStudioQueues()) {
|
for (const owner of listOwnersWithStudioQueues()) {
|
||||||
const store = await mutateStore(owner, (current) => {
|
const store = await mutateStore(owner, (current) => {
|
||||||
repairStaleJobs(current.jobs)
|
repairStaleJobs(current.jobs, current.paused === true)
|
||||||
const next = pruneDone(current.jobs)
|
const next = pruneDone(current.jobs)
|
||||||
current.jobs.splice(0, current.jobs.length, ...next)
|
current.jobs.splice(0, current.jobs.length, ...next)
|
||||||
return {
|
return {
|
||||||
@@ -402,12 +447,12 @@ async function dispatchStudioQueue() {
|
|||||||
jobs: current.jobs.map(item => structuredClone(item))
|
jobs: current.jobs.map(item => structuredClone(item))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
if (store.paused) return
|
||||||
const cutIn = pickCutIn(store.jobs)
|
const cutIn = pickCutIn(store.jobs)
|
||||||
if (cutIn) {
|
if (cutIn) {
|
||||||
await startStudioJob(cutIn)
|
await startStudioJob(cutIn)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.paused) return
|
|
||||||
const held = pickHeld(store.jobs)
|
const held = pickHeld(store.jobs)
|
||||||
if (held?.shotQueueId) {
|
if (held?.shotQueueId) {
|
||||||
await resumeHeldStudioJob(held)
|
await resumeHeldStudioJob(held)
|
||||||
@@ -606,7 +651,9 @@ export async function onLiveVideoSettled(job: Job) {
|
|||||||
: 0
|
: 0
|
||||||
const failed = job.status === 'error' || job.status === 'cancelled'
|
const failed = job.status === 'error' || job.status === 'cancelled'
|
||||||
const snapshot = readStore(owner)
|
const snapshot = readStore(owner)
|
||||||
const rowNow = snapshot.jobs.find(item => item.liveJobId === job.id) || snapshot.jobs.find(item => item.status === 'running')
|
const rowNow = snapshot.jobs.find(item => item.liveJobId === job.id)
|
||||||
|
|| snapshot.jobs.find(item => item.status === 'running' && item.shotQueueId && item.shotQueueId === job.library?.queueId)
|
||||||
|
|| snapshot.jobs.find(item => item.status === 'running' && !job.library?.queueId)
|
||||||
const userPause = !failed && (
|
const userPause = !failed && (
|
||||||
rowNow?.pauseAfterCurrent === true
|
rowNow?.pauseAfterCurrent === true
|
||||||
|| (snapshot.paused && !rowNow?.holdForCutIn)
|
|| (snapshot.paused && !rowNow?.holdForCutIn)
|
||||||
@@ -674,6 +721,10 @@ function applyLiveJobPause(live: Job | undefined, pause: boolean, restoreAutoRun
|
|||||||
live.library.stopAfterCurrent = pause
|
live.library.stopAfterCurrent = pause
|
||||||
if (pause) live.library.queueAutoRun = false
|
if (pause) live.library.queueAutoRun = false
|
||||||
else if (restoreAutoRun) live.library.queueAutoRun = true
|
else if (restoreAutoRun) live.library.queueAutoRun = true
|
||||||
|
patchPendingJob(live.id, {
|
||||||
|
stopAfterCurrent: pause,
|
||||||
|
queueAutoRun: live.library.queueAutoRun
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleStudioQueuePause(owner: string, options: {
|
export async function toggleStudioQueuePause(owner: string, options: {
|
||||||
@@ -754,10 +805,16 @@ export async function toggleStudioQueuePause(owner: string, options: {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const queueId = updated.job?.shotQueueId || options.queueId
|
||||||
if (updated.job?.liveJobId) {
|
if (updated.job?.liveJobId) {
|
||||||
applyLiveJobPause(getJob(updated.job.liveJobId), true)
|
applyLiveJobPause(getJob(updated.job.liveJobId), true)
|
||||||
|
patchPendingJob(updated.job.liveJobId, { stopAfterCurrent: true, queueAutoRun: false })
|
||||||
|
}
|
||||||
|
for (const pending of listPendingJobs()) {
|
||||||
|
if (pending.ownerKey !== owner) continue
|
||||||
|
if (queueId && pending.queueId && pending.queueId !== queueId) continue
|
||||||
|
patchPendingJob(pending.jobId, { stopAfterCurrent: true, queueAutoRun: false })
|
||||||
}
|
}
|
||||||
const queueId = updated.job?.shotQueueId || options.queueId
|
|
||||||
const queued = queueId ? getShotQueue(owner, queueId) : null
|
const queued = queueId ? getShotQueue(owner, queueId) : null
|
||||||
if (updated.job && queued?.autoRun) {
|
if (updated.job && queued?.autoRun) {
|
||||||
await patchStudioJob(owner, updated.job.id, (job) => {
|
await patchStudioJob(owner, updated.job.id, (job) => {
|
||||||
|
|||||||
+34
-15
@@ -9,7 +9,7 @@ import { isLtxWorkflow, isTextToVideo, parseVideoWorkflow, workflowForExtension,
|
|||||||
import { clipVideoPath, clipTitle, deleteRetryDraft, extendTempDir, getClip, nextClipPartName, removeExtendTemp, stillPath } from '~/server/utils/library'
|
import { clipVideoPath, clipTitle, deleteRetryDraft, extendTempDir, getClip, nextClipPartName, removeExtendTemp, stillPath } from '~/server/utils/library'
|
||||||
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
|
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
|
||||||
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
|
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
|
||||||
import { onLiveVideoSettled, onStudioQueueBurstStarted } from '~/server/utils/studioQueue'
|
import { onLiveVideoSettled, onStudioQueueBurstStarted, isStudioQueuePaused } from '~/server/utils/studioQueue'
|
||||||
import {
|
import {
|
||||||
finishQueueBurst,
|
finishQueueBurst,
|
||||||
getShotQueue,
|
getShotQueue,
|
||||||
@@ -238,6 +238,17 @@ function seedCurrentVideo(job: Job) {
|
|||||||
return { tmpDir, currentPath, part1Path: join(tmpDir, 'part1.mp4'), framePath: join(tmpDir, 'last_frame.png') }
|
return { tmpDir, currentPath, part1Path: join(tmpDir, 'part1.mp4'), framePath: join(tmpDir, 'last_frame.png') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chainShouldHold(job: Job) {
|
||||||
|
if (!job.library) return false
|
||||||
|
if (job.library.stopAfterCurrent) return true
|
||||||
|
if (isStudioQueuePaused(job.library.ownerKey)) return true
|
||||||
|
const queue = job.library.queueId
|
||||||
|
? getShotQueue(job.library.ownerKey, job.library.queueId)
|
||||||
|
: null
|
||||||
|
if (queue?.stopAfterCurrent || queue?.status === 'paused') return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export async function continueQueuedExtensions(
|
export async function continueQueuedExtensions(
|
||||||
job: Job,
|
job: Job,
|
||||||
params: VideoChainParams,
|
params: VideoChainParams,
|
||||||
@@ -269,14 +280,22 @@ export async function continueQueuedExtensions(
|
|||||||
for (let i = startFrom; i < extensions.length; i++) {
|
for (let i = startFrom; i < extensions.length; i++) {
|
||||||
if (shotsLeft <= 0) break
|
if (shotsLeft <= 0) break
|
||||||
assertJobActive(job)
|
assertJobActive(job)
|
||||||
if (job.library.stopAfterCurrent) break
|
if (chainShouldHold(job)) {
|
||||||
|
job.library.stopAfterCurrent = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
const liveQueue = job.library.queueId
|
const liveQueue = job.library.queueId
|
||||||
? getShotQueue(job.library.ownerKey, job.library.queueId)
|
? getShotQueue(job.library.ownerKey, job.library.queueId)
|
||||||
: null
|
: null
|
||||||
if (liveQueue?.stopAfterCurrent) job.library.stopAfterCurrent = true
|
if (liveQueue?.stopAfterCurrent || liveQueue?.autoRun === false) {
|
||||||
if (liveQueue?.autoRun === false) job.library.queueAutoRun = false
|
if (liveQueue.stopAfterCurrent) job.library.stopAfterCurrent = true
|
||||||
if (job.library.stopAfterCurrent) break
|
if (liveQueue.autoRun === false) job.library.queueAutoRun = false
|
||||||
|
}
|
||||||
|
if (chainShouldHold(job)) {
|
||||||
|
job.library.stopAfterCurrent = true
|
||||||
|
break
|
||||||
|
}
|
||||||
const live = liveQueue ? liveSegmentPrompt(liveQueue, i) : extensions[i]
|
const live = liveQueue ? liveSegmentPrompt(liveQueue, i) : extensions[i]
|
||||||
const ext = {
|
const ext = {
|
||||||
prompt: live.prompt || extensions[i].prompt,
|
prompt: live.prompt || extensions[i].prompt,
|
||||||
@@ -289,10 +308,7 @@ export async function continueQueuedExtensions(
|
|||||||
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
||||||
await sleep(3000)
|
await sleep(3000)
|
||||||
assertJobActive(job)
|
assertJobActive(job)
|
||||||
const pausedQueue = job.library.queueId
|
if (chainShouldHold(job)) {
|
||||||
? getShotQueue(job.library.ownerKey, job.library.queueId)
|
|
||||||
: null
|
|
||||||
if (job.library.stopAfterCurrent || pausedQueue?.stopAfterCurrent) {
|
|
||||||
job.library.stopAfterCurrent = true
|
job.library.stopAfterCurrent = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -376,7 +392,10 @@ export async function continueQueuedExtensions(
|
|||||||
job.library.extendPart1Path = undefined
|
job.library.extendPart1Path = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shotsLeft <= 0 || job.library.stopAfterCurrent) break
|
if (shotsLeft <= 0 || chainShouldHold(job)) {
|
||||||
|
if (chainShouldHold(job)) job.library.stopAfterCurrent = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (job.library) job.library.chainContinuing = false
|
if (job.library) job.library.chainContinuing = false
|
||||||
@@ -398,7 +417,10 @@ export async function continueQueuedExtensions(
|
|||||||
export async function continueQueuedExtensionsIfNeeded(job: Job) {
|
export async function continueQueuedExtensionsIfNeeded(job: Job) {
|
||||||
if (!job.library) return
|
if (!job.library) return
|
||||||
if (job.status === 'error' || job.status === 'cancelled' || job.status === 'complete') return
|
if (job.status === 'error' || job.status === 'cancelled' || job.status === 'complete') return
|
||||||
if (job.library.stopAfterCurrent) return
|
if (chainShouldHold(job)) {
|
||||||
|
job.library.stopAfterCurrent = true
|
||||||
|
return
|
||||||
|
}
|
||||||
const remaining = remainingAfterCurrentShot(job.library)
|
const remaining = remainingAfterCurrentShot(job.library)
|
||||||
if (!remaining.length) return
|
if (!remaining.length) return
|
||||||
const queue = job.library.queueId
|
const queue = job.library.queueId
|
||||||
@@ -440,10 +462,7 @@ export async function runGeneration(job: Job, params: VideoChainParams) {
|
|||||||
persist: extensions.length === 0 || !autoRun
|
persist: extensions.length === 0 || !autoRun
|
||||||
})
|
})
|
||||||
|
|
||||||
const stopNow = job.library?.stopAfterCurrent === true
|
const stopNow = chainShouldHold(job)
|
||||||
|| (job.library?.queueId
|
|
||||||
? getShotQueue(job.library.ownerKey, job.library.queueId)?.stopAfterCurrent === true
|
|
||||||
: false)
|
|
||||||
const keepGoing = Boolean(autoRun && !stopNow && extensions.length && job.library)
|
const keepGoing = Boolean(autoRun && !stopNow && extensions.length && job.library)
|
||||||
if (job.library?.queueId && !keepGoing) {
|
if (job.library?.queueId && !keepGoing) {
|
||||||
await finishQueueBurst(job.library.ownerKey, job.library.queueId, true).catch(() => null)
|
await finishQueueBurst(job.library.ownerKey, job.library.queueId, true).catch(() => null)
|
||||||
|
|||||||
+40
-3
@@ -4,6 +4,7 @@ import { getJob, restoreJob } from '~/server/utils/jobs'
|
|||||||
import type { PendingJob } from '~/server/utils/pending'
|
import type { PendingJob } from '~/server/utils/pending'
|
||||||
import { isLastChainShot, libraryFromPending, pendingFromJob, remainingAfterCurrentShot, writePendingJob, deletePendingJob } from '~/server/utils/pending'
|
import { isLastChainShot, libraryFromPending, pendingFromJob, remainingAfterCurrentShot, writePendingJob, deletePendingJob } from '~/server/utils/pending'
|
||||||
import { syncQueueFromJob } from '~/server/utils/shotQueue'
|
import { syncQueueFromJob } from '~/server/utils/shotQueue'
|
||||||
|
import { isEncodingNode, NODE_LABELS } from '~/server/utils/workflow'
|
||||||
import { mergePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
|
import { mergePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
function clipRefsFromJob(library: NonNullable<Job['library']>): PermanenceRef[] | undefined {
|
function clipRefsFromJob(library: NonNullable<Job['library']>): PermanenceRef[] | undefined {
|
||||||
@@ -52,6 +53,9 @@ export function mapChainProgress(job: Job, localPct: number) {
|
|||||||
export function formatChainMessage(job: Job, message: string, samplePct?: number) {
|
export function formatChainMessage(job: Job, message: string, samplePct?: number) {
|
||||||
const { chained, step, total, label } = chainMeta(job)
|
const { chained, step, total, label } = chainMeta(job)
|
||||||
if (!chained) return message
|
if (!chained) return message
|
||||||
|
if (/checkpoint saved|segment saved/i.test(message)) {
|
||||||
|
return `Shot ${step}/${total} saved`
|
||||||
|
}
|
||||||
if (typeof samplePct === 'number') {
|
if (typeof samplePct === 'number') {
|
||||||
return `Step ${step}/${total}: Generating ${label} (${samplePct}%)`
|
return `Step ${step}/${total}: Generating ${label} (${samplePct}%)`
|
||||||
}
|
}
|
||||||
@@ -78,6 +82,13 @@ export function emitChainJob(job: Job, event: JobEvent, samplePct?: number) {
|
|||||||
emitJob(job, next)
|
emitJob(job, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNonSamplerProgress(node: string) {
|
||||||
|
if (!node) return false
|
||||||
|
if (isEncodingNode(node)) return true
|
||||||
|
const label = NODE_LABELS[node] || ''
|
||||||
|
return /save|checkpoint|video combine|vhs/i.test(label) && !/sampler/i.test(label)
|
||||||
|
}
|
||||||
|
|
||||||
export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Promise<void> {
|
export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Promise<void> {
|
||||||
const persist = options.persist !== false
|
const persist = options.persist !== false
|
||||||
job.socketReady = false
|
job.socketReady = false
|
||||||
@@ -350,6 +361,11 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
|||||||
if (job.promptId && data.prompt_id && data.prompt_id !== job.promptId) return
|
if (job.promptId && data.prompt_id && data.prompt_id !== job.promptId) return
|
||||||
|
|
||||||
if (payload.type === 'progress') {
|
if (payload.type === 'progress') {
|
||||||
|
const node = data.node ? String(data.node) : ''
|
||||||
|
if (isNonSamplerProgress(node)) {
|
||||||
|
markActivity()
|
||||||
|
return
|
||||||
|
}
|
||||||
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)
|
||||||
@@ -360,7 +376,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
|||||||
step: value,
|
step: value,
|
||||||
maxStep: max,
|
maxStep: max,
|
||||||
progress: pct,
|
progress: pct,
|
||||||
node: data.node ? String(data.node) : null,
|
node: node || null,
|
||||||
message: `Sampling step ${value}/${max} (${samplePct}%)`
|
message: `Sampling step ${value}/${max} (${samplePct}%)`
|
||||||
}, samplePct)
|
}, samplePct)
|
||||||
}
|
}
|
||||||
@@ -424,20 +440,41 @@ export function ensurePendingWatch(pending: PendingJob) {
|
|||||||
pendingWatches.add(job.id)
|
pendingWatches.add(job.id)
|
||||||
const resume = async () => {
|
const resume = async () => {
|
||||||
try {
|
try {
|
||||||
|
const { applyPersistedPauseToJob, onLiveVideoSettled } = await import('~/server/utils/studioQueue')
|
||||||
|
applyPersistedPauseToJob(job)
|
||||||
|
const holdNow = () => job.library?.stopAfterCurrent === true
|
||||||
if (pending.promptId) {
|
if (pending.promptId) {
|
||||||
await watchComfyJob(job, { persist: lastShot })
|
await watchComfyJob(job, { persist: lastShot || holdNow() })
|
||||||
|
}
|
||||||
|
applyPersistedPauseToJob(job)
|
||||||
|
if (job.status === 'error' || job.status === 'cancelled') {
|
||||||
|
await onLiveVideoSettled(job)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (holdNow()) {
|
||||||
|
await onLiveVideoSettled(job)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (job.status === 'complete') {
|
||||||
|
await onLiveVideoSettled(job)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (job.status === 'error' || job.status === 'cancelled' || job.status === 'complete') return
|
|
||||||
if (!lastShot) {
|
if (!lastShot) {
|
||||||
const { continueQueuedExtensionsIfNeeded } = await import('~/server/utils/videoChain')
|
const { continueQueuedExtensionsIfNeeded } = await import('~/server/utils/videoChain')
|
||||||
await continueQueuedExtensionsIfNeeded(job)
|
await continueQueuedExtensionsIfNeeded(job)
|
||||||
}
|
}
|
||||||
|
applyPersistedPauseToJob(job)
|
||||||
|
if (job.status === 'error' || job.status === 'cancelled' || job.status === 'complete' || holdNow()) {
|
||||||
|
await onLiveVideoSettled(job)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
if (job.status === 'error' || job.status === 'cancelled') return
|
if (job.status === 'error' || job.status === 'cancelled') return
|
||||||
job.status = 'error'
|
job.status = 'error'
|
||||||
job.error = message
|
job.error = message
|
||||||
emitChainJob(job, { type: 'error', error: message, message })
|
emitChainJob(job, { type: 'error', error: message, message })
|
||||||
|
const { onLiveVideoSettled } = await import('~/server/utils/studioQueue')
|
||||||
|
await onLiveVideoSettled(job).catch(() => null)
|
||||||
} finally {
|
} finally {
|
||||||
pendingWatches.delete(job.id)
|
pendingWatches.delete(job.id)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user