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:
+39
-3
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user