Use the parent clip last frame and new-segment join for extensions, not the stitched file's first frame. Show this account's library storage in Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 10:54:46 -05:00
co-authored by Cursor
parent 231c59a7a7
commit 0af3cddb14
16 changed files with 571 additions and 70 deletions
+4 -1
View File
@@ -172,8 +172,11 @@ async function runExtend(
}
) {
copyFileSync(params.sourcePath, params.part1Path)
await extractLastFrame(params.part1Path, params.framePath)
await extractLastFrame(params.sourcePath, params.framePath)
const frame = readFileSync(params.framePath)
if (!frame.length || frame.length < 64) {
throw new Error('Could not extract the last frame of the clip being extended')
}
if (job.library) job.library.thumb = undefined
const sound = await probeHasAudio(params.part1Path)
+3 -2
View File
@@ -1,6 +1,6 @@
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, parseVideoWorkflow } from '~/utils/videoModels'
import { normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
function parseDuration(raw: unknown) {
const seconds = Number(raw)
@@ -121,7 +121,8 @@ export default defineEventHandler(async (event) => {
shotPermanenceRefs[index + 1] = item.permanenceRefs
}
}
const useIdentityRefs = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
const requestedIdentity = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
const useIdentityRefs = allowIdentityRefs(requestedIdentity, permanenceRefs, globalLocks, shotPermanenceRefs)
const { width, height } = resolveOutputSize(
fields.aspect,
image?.data || Buffer.alloc(0),
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=60, must-revalidate' : 'private, max-age=60, must-revalidate')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=60, must-revalidate' : 'private, max-age=60, must-revalidate')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
+4
View File
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
return libraryStorage(owner)
})
+39 -3
View File
@@ -59,6 +59,12 @@ function concatPath(filePath: string) {
return filePath.replace(/\\/g, '/').replace(/'/g, "'\\''")
}
function assertFrameFile(path: string, kind: string) {
if (!existsSync(path) || statSync(path).size < 64) {
throw new Error(`Could not extract the ${kind}`)
}
}
export async function extractLastFrame(inputPath: string, outputPath: string) {
if (!existsSync(inputPath)) {
throw new Error('Source video is missing')
@@ -80,7 +86,7 @@ export async function extractLastFrame(inputPath: string, outputPath: string) {
for (const args of attempts) {
try {
await runFfmpeg(args, 60000)
if (existsSync(outputPath)) return
if (existsSync(outputPath) && statSync(outputPath).size >= 64) return
} catch {
// Try the next seek strategy.
}
@@ -102,8 +108,38 @@ export async function extractFirstFrame(inputPath: string, outputPath: string) {
'-q:v', '4',
outputPath
], 20000)
if (!existsSync(outputPath)) {
throw new Error('Could not extract the first frame')
assertFrameFile(outputPath, 'first frame')
}
export async function extractFrameAt(inputPath: string, outputPath: string, seconds: number) {
if (!existsSync(inputPath)) {
throw new Error('Source video is missing')
}
const seek = Math.max(0, seconds)
await runFfmpeg([
'-y',
'-ss', seek.toFixed(3),
'-i', inputPath,
'-frames:v', '1',
'-update', '1',
'-an',
'-q:v', '2',
outputPath
], 60000)
assertFrameFile(outputPath, 'frame')
}
export async function extractFirstFrameFromBuffer(video: Buffer, tmpDir: string) {
if (!video.length) throw new Error('Could not extract the first frame')
const input = join(tmpDir, `seg_head_${Date.now()}.mp4`)
const output = join(tmpDir, `seg_first_${Date.now()}.jpg`)
writeFileSync(input, video)
try {
await extractFirstFrame(input, output)
return readFileSync(output)
} finally {
try { unlinkSync(input) } catch { /* ignore */ }
try { unlinkSync(output) } catch { /* ignore */ }
}
}
+91 -6
View File
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process'
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
import type { H3Event } from 'h3'
import { extractFirstFrame, extractLastFrame, remuxFaststart } from '~/server/utils/ffmpeg'
import { extractFirstFrame, extractFrameAt, extractLastFrame, probeDuration, remuxFaststart } from '~/server/utils/ffmpeg'
import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
const scryptAsync = promisify(scrypt)
@@ -122,7 +122,7 @@ interface Catalog {
const writeChains = new Map<string, Promise<unknown>>()
function libraryRoot() {
export function libraryRoot() {
const config = useRuntimeConfig()
return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '')
}
@@ -137,7 +137,7 @@ export function libraryOwnerKey(event: H3Event) {
return createHash('sha256').update(raw).digest('hex').slice(0, 24)
}
function ownerRoot(owner: string) {
export function ownerRoot(owner: string) {
return join(libraryRoot(), 'users', owner)
}
@@ -612,11 +612,69 @@ export async function ensureScaledThumb(source: string, dest: string) {
return build
}
type FrameSourceMeta = {
videoMtime: number
videoSize: number
mode: 'file-start' | 'segment-start' | 'last'
seek?: number
}
function frameMetaPath(dest: string) {
return `${dest}.src.json`
}
function readFrameMeta(dest: string): FrameSourceMeta | null {
try {
const raw = JSON.parse(readFileSync(frameMetaPath(dest), 'utf8')) as FrameSourceMeta
if (!raw || typeof raw.videoMtime !== 'number' || typeof raw.videoSize !== 'number') return null
return raw
} catch {
return null
}
}
function writeFrameMeta(dest: string, video: string, mode: FrameSourceMeta['mode'], seek?: number) {
try {
const info = statSync(video)
const meta: FrameSourceMeta = { videoMtime: info.mtimeMs, videoSize: info.size, mode }
if (typeof seek === 'number') meta.seek = seek
writeFileSync(frameMetaPath(dest), JSON.stringify(meta))
} catch { /* thumbs still work without the sidecar */ }
}
function frameCacheFresh(video: string, dest: string, mode: FrameSourceMeta['mode']) {
if (!existsSync(dest) || statSync(dest).size < 64) return false
try {
const info = statSync(video)
const meta = readFrameMeta(dest)
if (meta) {
return meta.mode === mode && meta.videoMtime === info.mtimeMs && meta.videoSize === info.size
}
return statSync(dest).mtimeMs >= info.mtimeMs && mode === 'file-start'
} catch {
return false
}
}
async function parentVideoDuration(owner: string, parentId?: string) {
if (!parentId) return 0
const video = clipVideoPath(owner, parentId)
if (!existsSync(video)) return 0
try {
return await probeDuration(video)
} catch {
return 0
}
}
export async function ensureClipLastFrame(owner: string, id: string) {
const video = clipVideoPath(owner, id)
const dest = clipLastFramePath(owner, id)
if (!existsSync(video)) return ''
if (!existsSync(dest)) await extractLastFrame(video, dest)
if (!frameCacheFresh(video, dest, 'last')) {
await extractLastFrame(video, dest)
writeFrameMeta(dest, video, 'last')
}
return existsSync(dest) ? dest : ''
}
@@ -624,8 +682,29 @@ export async function ensureClipFirstFrame(owner: string, id: string) {
const video = clipVideoPath(owner, id)
const dest = clipFirstFramePath(owner, id)
if (!existsSync(video)) return ''
if (!existsSync(dest)) await extractFirstFrame(video, dest)
return existsSync(dest) ? dest : ''
const catalog = readCatalog(owner)
const clip = catalog.clips.find(item => item.id === id)
const parentDur = await parentVideoDuration(owner, clip?.parentClipId)
let fileDur = 0
try {
fileDur = await probeDuration(video)
} catch { /* treat as a single segment */ }
const stored = Number(clip?.duration) || 0
const stitched = (parentDur > 0.5 && fileDur > parentDur + 0.4)
|| (stored > 0.5 && fileDur > stored + 0.8)
const seek = parentDur > 0.5 && fileDur > parentDur + 0.4
? parentDur
: (stitched && stored > 0.5 ? Math.max(0, fileDur - stored) : 0)
const mode: FrameSourceMeta['mode'] = stitched ? 'segment-start' : 'file-start'
if (frameCacheFresh(video, dest, mode)) return dest
if (stitched) {
await extractFrameAt(video, dest, seek)
writeFrameMeta(dest, video, 'segment-start', seek)
return dest
}
await extractFirstFrame(video, dest)
writeFrameMeta(dest, video, 'file-start', 0)
return dest
}
export function extendTempDir(owner: string, jobId: string) {
@@ -1313,6 +1392,7 @@ export async function saveClip(params: {
sound?: boolean
globalLocks?: string
permanenceRefs?: PermanenceRef[]
segmentFirstFrame?: Buffer
}) {
const catalog = readCatalog(params.ownerKey)
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
@@ -1349,6 +1429,11 @@ export async function saveClip(params: {
const videoPath = clipVideoPath(params.ownerKey, clip.id)
writeFileSync(videoPath, params.video)
await remuxFaststart(videoPath)
if (params.segmentFirstFrame?.length && params.segmentFirstFrame.length >= 64) {
const firstPath = clipFirstFramePath(params.ownerKey, clip.id)
writeFileSync(firstPath, params.segmentFirstFrame)
writeFrameMeta(firstPath, videoPath, 'segment-start')
}
if (typeof params.duration === 'number' && params.duration > 0) {
clip.duration = params.duration
} else {
+9 -1
View File
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { Job } from '~/server/utils/jobs'
import { extractFirstFrameFromBuffer } from '~/server/utils/ffmpeg'
import { mergePermanenceRefs } from '~/utils/globalLocks'
export interface PendingJob {
@@ -270,11 +271,17 @@ export async function completePendingIfReady(pending: PendingJob) {
if (inFlight()) return null
const afterDownload = alreadySaved()
if (afterDownload) return afterDownload
let segmentFirstFrame: Buffer | undefined
if (pending.extendPart1Path && pending.extendTmpDir) {
if (!existsSync(pending.extendPart1Path)) {
removeExtendTemp(pending.extendTmpDir)
throw createError({ statusCode: 500, statusMessage: 'Extension source clip was missing during stitch' })
}
try {
segmentFirstFrame = await extractFirstFrameFromBuffer(buffer, pending.extendTmpDir)
} catch {
// ensureClipFirstFrame will seek to the join if this frame is missing
}
try {
buffer = await stitchExtension({
part1Path: pending.extendPart1Path,
@@ -310,7 +317,8 @@ export async function completePendingIfReady(pending: PendingJob) {
stillId: pending.stillId,
sound: pending.sound,
globalLocks: pending.globalLocks,
permanenceRefs: mergePermanenceRefs(pending.permanenceRefs, pending.shotPermanenceRefs?.[pending.chainIndex || 0])
permanenceRefs: mergePermanenceRefs(pending.permanenceRefs, pending.shotPermanenceRefs?.[pending.chainIndex || 0]),
segmentFirstFrame
})
await purgeComfyArtifacts({
video,
+193
View File
@@ -0,0 +1,193 @@
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { statfs } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { dirname, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { libraryRoot, ownerRoot } from '~/server/utils/library'
const execFileAsync = promisify(execFile)
const OVERLAY_TYPES = new Set(['overlay', 'aufs', 'fuse-overlayfs', 'overlayfs'])
export interface StorageBreakdown {
clips: number
stills: number
tmp: number
other: number
}
export interface VolumeStats {
ok: boolean
totalBytes: number | null
usedBytes: number | null
freeBytes: number | null
overlay: boolean
}
export interface LibraryStorage {
libraryBytes: number
breakdown: StorageBreakdown
volume: VolumeStats
counted: string
}
function emptyBreakdown(): StorageBreakdown {
return { clips: 0, stills: 0, tmp: 0, other: 0 }
}
function bucketFor(prefix: string): keyof StorageBreakdown {
if (prefix === 'files') return 'clips'
if (prefix === 'stills') return 'stills'
if (prefix === 'tmp') return 'tmp'
return 'other'
}
function walkDir(dir: string, breakdown: StorageBreakdown, prefix: string) {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (entry.isSymbolicLink()) continue
const full = join(dir, entry.name)
if (entry.isDirectory()) {
walkDir(full, breakdown, prefix || entry.name)
continue
}
if (!entry.isFile()) continue
let size = 0
try {
size = statSync(full).size
} catch {
continue
}
breakdown[bucketFor(prefix)] += size
}
}
function ownerLibraryBytes(owner: string) {
const breakdown = emptyBreakdown()
const root = ownerRoot(owner)
if (existsSync(root)) walkDir(root, breakdown, '')
return {
libraryBytes: breakdown.clips + breakdown.stills + breakdown.tmp + breakdown.other,
breakdown
}
}
function decodeMountField(value: string) {
return value.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)))
}
function mountFor(target: string): { mountPoint: string; fstype: string; source: string } | null {
if (!existsSync('/proc/self/mountinfo')) return null
let text = ''
try {
text = readFileSync('/proc/self/mountinfo', 'utf8')
} catch {
return null
}
const normalized = target.replace(/\\/g, '/').replace(/\/$/, '') || '/'
let best: { mountPoint: string; fstype: string; source: string } | null = null
for (const line of text.split('\n')) {
if (!line.trim()) continue
const parts = line.split(' - ')
if (parts.length < 2) continue
const left = parts[0].split(' ')
const right = parts[1].split(' ')
const mountPoint = decodeMountField(left[4] || '')
const fstype = right[0] || ''
const source = decodeMountField(right[1] || '')
if (!mountPoint) continue
const prefix = mountPoint === '/' ? '/' : `${mountPoint.replace(/\/$/, '')}/`
const hit = normalized === mountPoint || normalized.startsWith(prefix) || `${normalized}/` === prefix
if (!hit) continue
if (!best || mountPoint.length > best.mountPoint.length) {
best = { mountPoint, fstype, source }
}
}
return best
}
function isOverlayFs(fstype: string, source: string) {
const type = (fstype || '').toLowerCase()
const src = (source || '').toLowerCase()
return OVERLAY_TYPES.has(type) || src === 'overlay'
}
async function volumeFromStatfs(path: string): Promise<Omit<VolumeStats, 'overlay'> | null> {
try {
const stats = await statfs(path)
const bsize = Number(stats.bsize)
const total = Number(stats.blocks) * bsize
const free = Number(stats.bavail) * bsize
if (!Number.isFinite(total) || total <= 0) return null
return {
ok: true,
totalBytes: total,
usedBytes: Math.max(0, total - (Number.isFinite(free) ? free : 0)),
freeBytes: Number.isFinite(free) ? Math.max(0, free) : null
}
} catch {
return null
}
}
async function volumeFromDf(path: string): Promise<Omit<VolumeStats, 'overlay'> | null> {
try {
const { stdout } = await execFileAsync('df', ['-kP', path], { timeout: 4000, windowsHide: true })
const lines = String(stdout || '').trim().split(/\r?\n/).filter(Boolean)
const row = lines[lines.length - 1]
if (!row || lines.length < 2) return null
const cols = row.trim().split(/\s+/)
if (cols.length < 6) return null
const totalK = Number(cols[cols.length - 5])
const usedK = Number(cols[cols.length - 4])
const availK = Number(cols[cols.length - 3])
if (!Number.isFinite(totalK) || totalK <= 0) return null
const total = totalK * 1024
const free = Number.isFinite(availK) ? availK * 1024 : null
const used = Number.isFinite(usedK) ? usedK * 1024 : (free == null ? null : Math.max(0, total - free))
return { ok: true, totalBytes: total, usedBytes: used, freeBytes: free }
} catch {
return null
}
}
function volumeProbePath() {
const root = libraryRoot()
const candidates = [root, dirname(resolve(root))]
for (const candidate of candidates) {
if (candidate && existsSync(candidate)) {
try {
return realpathSync(candidate)
} catch {
return resolve(candidate)
}
}
}
return resolve(root)
}
async function volumeStats(): Promise<VolumeStats> {
const path = volumeProbePath()
const mount = mountFor(path.replace(/\\/g, '/'))
const overlay = mount ? isOverlayFs(mount.fstype, mount.source) : false
const fromStatfs = await volumeFromStatfs(path)
if (fromStatfs) return { ...fromStatfs, overlay }
const fromDf = await volumeFromDf(path)
if (fromDf) return { ...fromDf, overlay }
return { ok: false, totalBytes: null, usedBytes: null, freeBytes: null, overlay }
}
export async function libraryStorage(owner: string): Promise<LibraryStorage> {
const { libraryBytes, breakdown } = ownerLibraryBytes(owner)
return {
libraryBytes,
breakdown,
volume: await volumeStats(),
counted: "This account's clips, stills, thumbs, catalogs, queues, and temp files. Shared pending JSON and other accounts are not included."
}
}
+10 -4
View File
@@ -4,7 +4,7 @@ import { getJob, listJobs, emitJob, type Job } from '~/server/utils/jobs'
import { listPendingJobs, patchPendingJob, readPendingJob, deletePendingJob } from '~/server/utils/pending'
import { getShotQueue } from '~/server/utils/shotQueue'
import { parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
import type { PermanenceRef } from '~/utils/globalLocks'
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
export type StudioJobKind = 'video' | 'edit'
@@ -680,6 +680,12 @@ export async function startStudioJob(item: StudioJob) {
const { existsSync, readFileSync } = await import('node:fs')
const payload = item.payload
const identity = allowIdentityRefs(
payload.useIdentityRefs,
payload.permanenceRefs,
payload.globalLocks,
payload.shotPermanenceRefs
)
const job = createJob()
job.kind = 'video'
job.maxStep = payload.steps
@@ -715,7 +721,7 @@ export async function startStudioJob(item: StudioJob) {
chainLabel: extensions.length ? 'Initial' : undefined,
familyId: item.familyId,
workflow: parseVideoWorkflow(payload.workflow),
useIdentityRefs: payload.useIdentityRefs,
useIdentityRefs: identity,
queueAutoRun: extensions.length ? payload.queueAutoRun : false,
queueBudget: extensions.length && payload.queueAutoRun ? extensions.length : 0,
globalLocks: payload.globalLocks,
@@ -745,7 +751,7 @@ export async function startStudioJob(item: StudioJob) {
scheduler: payload.scheduler,
workflow: parseVideoWorkflow(payload.workflow),
sound: payload.sound,
useIdentityRefs: payload.useIdentityRefs,
useIdentityRefs: identity,
referenceStillIds: payload.referenceStillIds,
globalLocks: payload.globalLocks,
permanenceRefs: payload.permanenceRefs,
@@ -798,7 +804,7 @@ export async function startStudioJob(item: StudioJob) {
extensions,
workflow: parseVideoWorkflow(payload.workflow),
duration: payload.duration,
useIdentityRefs: payload.useIdentityRefs,
useIdentityRefs: identity,
referenceImages
}).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
+29 -7
View File
@@ -20,7 +20,7 @@ import {
setQueueJob,
updateShotQueue
} from '~/server/utils/shotQueue'
import { composeShotPrompt, type PermanenceRef } from '~/utils/globalLocks'
import { composeShotPrompt, allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
export type ChainImage = { filename: string; data: Buffer; type?: string }
@@ -110,7 +110,12 @@ function paramsFromJob(job: Job): VideoChainParams {
extensions: library.extensions || [],
workflow,
duration,
useIdentityRefs: library.useIdentityRefs === true,
useIdentityRefs: allowIdentityRefs(
library.useIdentityRefs === true,
library.permanenceRefs,
library.globalLocks,
library.shotPermanenceRefs
),
referenceImages,
globalLocks: library.globalLocks,
permanenceRefs: library.permanenceRefs,
@@ -336,8 +341,11 @@ export async function continueQueuedExtensions(
}
copyFileSync(currentPath, part1Path)
const parentId = job.library.parentClipId
const parentVideo = parentId ? clipVideoPath(job.library.ownerKey, parentId) : ''
const extractSource = parentVideo && existsSync(parentVideo) ? parentVideo : part1Path
try {
await extractLastFrame(part1Path, framePath)
await extractLastFrame(extractSource, framePath)
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
throw new Error(detail.includes('last frame')
@@ -345,6 +353,9 @@ export async function continueQueuedExtensions(
: `Could not extract the last frame for the next extension: ${detail}`)
}
const frame = readFileSync(framePath)
if (!frame.length || frame.length < 64) {
throw new Error('Could not extract the last frame for the next extension: the frame file was empty')
}
job.library.extendPart1Path = part1Path
job.library.prompt = ext.prompt
if (live.permanenceRefs?.length) {
@@ -354,11 +365,17 @@ export async function continueQueuedExtensions(
}
const sound = await probeHasAudio(currentPath)
const seed = Math.floor(Math.random() * 2_147_483_647)
const identity = allowIdentityRefs(
params.useIdentityRefs,
job.library.permanenceRefs || params.permanenceRefs,
job.library.globalLocks || params.globalLocks,
job.library.shotPermanenceRefs || params.shotPermanenceRefs
)
await ensureComfyReady(ready)
await queueMiniMax(job, {
prompt: ext.prompt,
image: params.useIdentityRefs
image: identity
? params.image
: { filename: 'last_frame.png', data: frame, type: 'image/png' },
width: params.width,
@@ -375,8 +392,8 @@ export async function continueQueuedExtensions(
persist,
workflow: workflowForExtension(params.workflow),
duration: ext.duration,
useIdentityRefs: params.useIdentityRefs,
referenceImages: params.useIdentityRefs ? params.referenceImages : []
useIdentityRefs: identity,
referenceImages: identity ? params.referenceImages : []
})
shotsLeft -= 1
@@ -520,7 +537,12 @@ export async function startQueueBurst(owner: string, queueId: string, count: num
familyId: queue.familyId,
parentClipId: clipId,
workflow: queue.workflow,
useIdentityRefs: queue.useIdentityRefs,
useIdentityRefs: allowIdentityRefs(
queue.useIdentityRefs === true,
queue.permanenceRefs,
queue.globalLocks,
queue.segments.map(segment => segment.permanenceRefs || [])
),
queueId: queue.id,
queueAutoRun: count === 'all',
queueBudget: n,
+11 -1
View File
@@ -5,6 +5,7 @@ import type { PendingJob } from '~/server/utils/pending'
import { isLastChainShot, libraryFromPending, pendingFromJob, remainingAfterCurrentShot, writePendingJob, deletePendingJob } from '~/server/utils/pending'
import { syncQueueFromJob } from '~/server/utils/shotQueue'
import { isEncodingNode, NODE_LABELS } from '~/server/utils/workflow'
import { extractFirstFrameFromBuffer } from '~/server/utils/ffmpeg'
import { mergePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
function clipRefsFromJob(library: NonNullable<Job['library']>): PermanenceRef[] | undefined {
@@ -186,10 +187,18 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
})
if (job.library) {
let buffer = await downloadComfyVideo(video)
let segmentFirstFrame: Buffer | undefined
if (stitching) {
if (!existsSync(job.library.extendPart1Path!)) {
throw new Error('Extension source clip was missing during stitch')
}
if (job.library.extendTmpDir) {
try {
segmentFirstFrame = await extractFirstFrameFromBuffer(buffer, job.library.extendTmpDir)
} catch {
// ensureClipFirstFrame will seek to the join if this frame is missing
}
}
buffer = await stitchExtension({
part1Path: job.library.extendPart1Path!,
part2: buffer,
@@ -225,7 +234,8 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
stillId: job.library.stillId,
sound: job.library.sound,
globalLocks: job.library.globalLocks,
permanenceRefs: clipRefsFromJob(job.library)
permanenceRefs: clipRefsFromJob(job.library),
segmentFirstFrame
})
job.clipId = clip.id
job.savedPromptId = job.promptId