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 -4
View File
@@ -126,16 +126,16 @@ const visibleSlots = computed(() => {
function frameThumb(key: SlotKey) {
if (concealed.value) return ''
if (key === 'still' && props.stillId) return `/api/library/stills/${props.stillId}?thumb=1`
if (key === 'from' && props.fromClipId) return `/api/library/clips/${props.fromClipId}/last-frame?thumb=1`
if (key === 'first' && props.clipId) return `/api/library/clips/${props.clipId}/first-frame?thumb=1`
if (key === 'from' && props.fromClipId) return `/api/library/clips/${props.fromClipId}/last-frame?thumb=1&v=parent`
if (key === 'first' && props.clipId) return `/api/library/clips/${props.clipId}/first-frame?thumb=1&v=join`
return ''
}
function frameFull(key: SlotKey) {
if (concealed.value) return ''
if (key === 'still' && props.stillId) return `/api/library/stills/${props.stillId}`
if (key === 'from' && props.fromClipId) return `/api/library/clips/${props.fromClipId}/last-frame`
if (key === 'first' && props.clipId) return `/api/library/clips/${props.clipId}/first-frame`
if (key === 'from' && props.fromClipId) return `/api/library/clips/${props.fromClipId}/last-frame?v=parent`
if (key === 'first' && props.clipId) return `/api/library/clips/${props.clipId}/first-frame?v=join`
return ''
}
+78 -1
View File
@@ -5,11 +5,24 @@
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="font-display text-2xl font-bold">Settings</h2>
<p class="mt-1 text-sm text-zinc-400">Library folders, passwords, and prompt-helper instruction sets for this account.</p>
<p class="mt-1 text-sm text-zinc-400">Library storage, folders, passwords, and prompt-helper instruction sets for this account.</p>
</div>
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="emit('close')">Close</button>
</div>
<section class="mt-6">
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Storage</h3>
<div class="mt-2 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
<p v-if="storageLoading" class="text-sm text-zinc-500">Measuring…</p>
<p v-else-if="storageError" class="text-sm text-zinc-500">{{ storageError }}</p>
<template v-else-if="storage">
<p class="text-sm text-zinc-200">{{ storageLine }}</p>
<p v-if="storageBreakdown" class="mt-1 text-xs text-zinc-500">{{ storageBreakdown }}</p>
<p class="mt-1 text-xs text-zinc-600">{{ storageNote }}</p>
</template>
</div>
</section>
<section v-if="authMode === 'password'" class="mt-6 space-y-3">
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Site login</h3>
<p class="text-sm text-zinc-400">Username and password are stored encrypted on this server. Changing them does not move the library.</p>
@@ -207,6 +220,56 @@ const loginUser = ref(props.username || '')
const loginCurrent = ref('')
const loginNext = ref('')
const drafts = reactive<Record<string, { name: string; protect: boolean; password: string; currentPassword: string }>>({})
const storageLoading = ref(true)
const storageError = ref('')
const storage = ref<{
libraryBytes: number
breakdown: { clips: number; stills: number; tmp: number; other: number }
volume: { ok: boolean; totalBytes: number | null; usedBytes: number | null; freeBytes: number | null; overlay: boolean }
} | null>(null)
function formatBytes(bytes: number) {
const n = Math.max(0, Number(bytes) || 0)
if (n < 1024) return `${Math.round(n)} B`
const units = ['KB', 'MB', 'GB', 'TB']
let value = n / 1024
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
const digits = value >= 10 || units[unit] === 'KB' ? 0 : 1
return `${value.toFixed(digits)} ${units[unit]}`
}
const storageLine = computed(() => {
if (!storage.value) return ''
const library = `Library ${formatBytes(storage.value.libraryBytes)}`
const volume = storage.value.volume
if (!volume.ok || volume.freeBytes == null || volume.totalBytes == null) {
return `${library} · free space unavailable`
}
const free = `${formatBytes(volume.freeBytes)} free of ${formatBytes(volume.totalBytes)}`
return volume.overlay ? `${library} · ${free} on the container disk` : `${library} · ${free}`
})
const storageBreakdown = computed(() => {
if (!storage.value) return ''
const { clips, stills, tmp } = storage.value.breakdown
const parts = [
clips ? `Clips ${formatBytes(clips)}` : '',
stills ? `Stills ${formatBytes(stills)}` : '',
tmp ? `Temp ${formatBytes(tmp)}` : ''
].filter(Boolean)
return parts.join(' · ')
})
const storageNote = computed(() => {
if (!storage.value) return ''
return storage.value.volume.overlay
? "This account's clips, stills, thumbs, and temp. Free space is the container disk, not a mounted volume."
: "This account's clips, stills, thumbs, and temp. Volume free space is shared."
})
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') emit('close')
@@ -215,6 +278,7 @@ function onKey(event: KeyboardEvent) {
onMounted(() => {
window.addEventListener('keydown', onKey)
void loadPresets()
void loadStorage()
})
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
@@ -280,6 +344,19 @@ async function loadPresets() {
promptPresets.value = data.presets || []
}
async function loadStorage() {
storageLoading.value = true
storageError.value = ''
try {
storage.value = await $fetch('/api/settings/storage')
} catch (error: any) {
storage.value = null
storageError.value = error?.data?.statusMessage || error?.statusMessage || 'Could not read storage'
} finally {
storageLoading.value = false
}
}
async function addPreset() {
const name = newPresetName.value.trim()
const systemPrompt = newPresetPrompt.value.trim()
+37 -18
View File
@@ -290,8 +290,7 @@
@update-label="updateFamilyRefLabel"
/>
<p class="mt-2 text-[11px] leading-relaxed text-zinc-500">
Name each still, or pick a GLOBAL LOCKS key. Shot prompts inherit those names.
The generate start still stays the first frame — attach it here only if you want a named alias.
MiniMax will not see these photos. Continuity is the last frame of the previous clip. The stills only name the lock lines.
</p>
<p class="mt-1 text-[11px] leading-relaxed text-zinc-500">{{ permanenceComfyNote }}</p>
<p v-if="familyRefLabelLine" class="mt-1 text-[11px] text-amber-200/80">{{ familyRefLabelLine }}</p>
@@ -515,18 +514,18 @@
</div>
<div v-if="videoEngine === 'minimax' && videoStart === 'still' && minimaxGraph === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 space-y-3">
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm">
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm" :class="extraPersonLock ? 'cursor-not-allowed opacity-60' : ''">
<span>
<span class="block font-medium text-zinc-200">Use identity references</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off uses the start still as first-frame only. On keeps that still as Picture 1. Extra stills are optional identity views of the same person, not extra characters.</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off uses last-frame I2V after shot 1. On keeps the start still as Picture 1 — extra stills are more views of that same person, never a second character. A named extra person forces last-frame I2V.</span>
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="useIdentityRefs" type="checkbox" class="peer sr-only" role="switch">
<input v-model="useIdentityRefs" type="checkbox" class="peer sr-only" role="switch" :disabled="extraPersonLock">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<div v-if="useIdentityRefs" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div v-if="useIdentityRefs && !extraPersonLock" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div
v-for="slot in 4"
:key="slot"
@@ -564,7 +563,8 @@
</button>
</div>
</div>
<p v-if="useIdentityRefs" class="text-xs text-zinc-500">Picture 1 is the start still and stays Picture 1 on every shot, including queued extensions. Extra stills map to Picture 2–5 only if you add them — extra views of that same person, not a second character or a vehicle. Empty slots are left out of the graph, not filled with copies of Picture 1.</p>
<p v-if="extraPersonLock" class="text-xs text-zinc-500">Identity refs stay off while a second named person is attached. MiniMax will not see the extra stills; shot 2+ starts from the parent clip’s last frame.</p>
<p v-else-if="useIdentityRefs" class="text-xs text-zinc-500">Picture 1 is the start still and stays Picture 1 on every shot, including queued extensions. Extra stills map to Picture 2–5 only if you add them — extra views of that same person, not a second character or a vehicle. Empty slots are left out of the graph, not filled with copies of Picture 1.</p>
</div>
</div>
@@ -1244,9 +1244,9 @@
:hidden="item.hideThumbnail"
:revealed="!!stitchReveal[item.id]"
:still-id="partIndex === 0 ? item.stillId : undefined"
:from-clip-id="previousChronoClipId(entry.group.parts, partIndex)"
:from-clip-id="previousClipIdFor(item, entry.group.parts, partIndex)"
:clip-id="item.id"
:show-first-frame="partIndex > 0 || !item.stillId"
:show-first-frame="partIndex > 0 || !item.stillId || Boolean(item.parentClipId)"
@update:revealed="stitchReveal[item.id] = $event"
/>
<div class="min-w-0 flex-1">
@@ -1541,10 +1541,12 @@ import {
composeShotPrompt,
extractGlobalLocks,
isBlankGlobalLocks,
mergePermanenceRefs,
nextPermanenceLabel,
parseLockBindingKeys,
permanenceLabels,
sanitizeRefLabel,
shouldForceLastFrameI2V,
type PermanenceRef
} from '~/utils/globalLocks'
import {
@@ -1890,11 +1892,18 @@ const familyRefLabelLine = computed(() => {
if (!labels.length) return ''
return `In the prompt: Permanence: ${labels.join(', ')} stay the same.`
})
const extraPersonLock = computed(() => shouldForceLastFrameI2V(
mergePermanenceRefs(familyPermanenceRefs.value, Object.values(shotPermanenceRefs.value).flat()),
globalLocks.value
))
const permanenceComfyNote = computed(() => {
if (extraPersonLock.value) {
return 'A second named person keeps last-frame I2V on. MiniMax never sees the man/bench photos; those names only go in the inherit line.'
}
if (studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value) {
return 'Identity-ref still only maps extra views of the start-still person, never a second character. Permanence stills name the lock text; they are not Picture 2–5.'
}
return 'Last-frame I2V does not send these stills to MiniMax as extra pictures; they name what the lock text is talking about. Identity-ref still only maps extra views of the start-still person, never a second character.'
return 'Last-frame I2V does not send these stills to MiniMax as extra pictures. Continuity is the previous clip’s last frame; the names only go in the lock line.'
})
let videoTimer: ReturnType<typeof setInterval> | null = null
let editTimer: ReturnType<typeof setInterval> | null = null
@@ -2117,7 +2126,7 @@ const recommendedShots = computed(() => {
if (!recommendText.value || recommendBusy.value) return []
return parseShotScript(recommendText.value)
})
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value)
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value)
const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : []))
function composedShotPreview(text: string, index: number, shotN?: number) {
const composed = composeShotPrompt({
@@ -2322,9 +2331,16 @@ function chronologicalParts(parts: LibraryClip[]) {
})
}
function previousChronoClipId(parts: LibraryClip[], index: number) {
function previousClipIdFor(item: LibraryClip, parts: LibraryClip[], index: number) {
if (item.parentClipId) return item.parentClipId
if (index <= 0) return undefined
return chronologicalParts(parts)[index - 1]?.id
const ordered = chronologicalParts(parts)
const current = ordered[index]
const currentChain = current?.chainIndex || 0
for (let i = index - 1; i >= 0; i--) {
if ((ordered[i].chainIndex || 0) < currentChain) return ordered[i].id
}
return ordered[index - 1]?.id
}
function collectionTitle(group: { head: LibraryClip; parts: LibraryClip[] }) {
@@ -2688,6 +2704,10 @@ watch(useIdentityRefs, (value) => {
} catch { /* ignore */ }
})
watch(extraPersonLock, (locked) => {
if (locked) useIdentityRefs.value = false
})
watch([globalLocks, familyPermanenceRefs, shotPermanenceRefs], () => {
try {
localStorage.setItem(LOCKS_STORE, globalLocks.value)
@@ -3502,9 +3522,8 @@ async function loadRerunImage(item: LibraryClip, parts: LibraryClip[]) {
const index = Math.max(0, parts.findIndex(part => part.id === item.id))
const isExtension = index > 0 || Boolean(item.parentClipId) || (item.chainIndex || 0) > 0
if (isExtension) {
const previous = index > 0
? parts[index - 1]
: clips.value.find(clip => clip.id === item.parentClipId)
const previous = (item.parentClipId && clips.value.find(clip => clip.id === item.parentClipId))
|| (index > 0 ? parts[index - 1] : undefined)
if (previous) {
const blob = await $fetch<Blob>(`/api/library/clips/${previous.id}/last-frame`, { responseType: 'blob' })
readFile(new File([blob], 'last_frame.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true })
@@ -4401,8 +4420,8 @@ async function generate() {
body.append('sound', String(withSound.value))
body.append('name', clipName.value.trim())
body.append('workflow', videoWorkflow.value)
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value))
if (videoWorkflow.value === 'v2' && useIdentityRefs.value) {
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value))
if (videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value) {
identityRefs.value.forEach((ref, index) => {
if (ref) body.append(`refImage${index}`, ref)
})
+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
+57 -20
View File
@@ -101,28 +101,64 @@ export function resolveGlobalLocks(locks: string, firstPrompt: string) {
: { locks: '', prompt: firstPrompt }
}
function extractState(locks: string) {
const match = String(locks || '').match(/(?:^|\n)\s*State:\s*(.+?)(?=\n\s*[A-Za-z][A-Za-z0-9 _-]{0,22}\s*:|\n\s*\[|$)/is)
return String(match?.[1] || '').replace(/\s+/g, ' ').trim()
}
export function permanenceLabels(refs: PermanenceRef[] | null | undefined) {
return [...new Set(normalizePermanenceRefs(refs).map(item => item.label).filter(Boolean))]
}
const PERSON_WORD = /\b(man|men|woman|women|person|people|boy|girl|guy|lady|male|female|human|child|kid|figure|character|actor|actress)\b/i
const MAIN_SUBJECT_ALIAS = /^(subject|woman|women|she|her|female)$/i
export function extraPersonLabels(refs: PermanenceRef[] | null | undefined) {
return permanenceLabels(refs).filter(label => {
if (MAIN_SUBJECT_ALIAS.test(label)) return false
return PERSON_WORD.test(label) || /^extras?$/i.test(label)
})
}
export function shouldForceLastFrameI2V(refs?: PermanenceRef[] | null, _locks = '') {
return extraPersonLabels(refs).length > 0
}
export function allowIdentityRefs(
requested: boolean,
familyRefs?: PermanenceRef[] | null,
locks = '',
shotRefs?: PermanenceRef[][] | null
) {
if (!requested) return false
return !shouldForceLastFrameI2V(mergePermanenceRefs(familyRefs, (shotRefs || []).flat()), locks)
}
function permanenceLine(refs: PermanenceRef[]) {
const labels = permanenceLabels(refs)
if (!labels.length) return ''
return `Permanence: ${labels.join(', ')} stay the same.`
}
function inheritLine(locks: string, refs: PermanenceRef[]) {
const parts = ['[INHERITS] Global locks.']
function inheritLine(refs: PermanenceRef[]) {
const labels = permanenceLabels(refs)
if (labels.length) parts.push(`Same ${labels.join(', ')} as locked.`)
const state = extractState(locks)
if (state) parts.push(`State: ${state}`)
return parts.join(' ')
if (labels.length) return `[INHERITS] Same ${labels.join(', ')} as now`
return '[INHERITS] Same as now'
}
export function stripLockHeader(prompt: string) {
let src = String(prompt || '').replace(/\r\n/g, '\n').trim()
while (/^\[(GLOBAL LOCKS|INHERITS)\]/i.test(src)) {
if (/^\[GLOBAL LOCKS\]/i.test(src)) {
const config = src.search(/\n\[SHOT CONFIGURATION\]/i)
if (config >= 0) {
src = src.slice(config).trim()
break
}
}
const split = src.search(/\n\s*\n/)
src = (split >= 0 ? src.slice(split) : src.replace(/^\s*\[[^\]]+\][^\n]*/, '')).trim()
if (/^\[GLOBAL LOCKS\]/i.test(src)) {
src = src.replace(/^\s*\[GLOBAL LOCKS\]\s*\n?/, '').trim()
break
}
}
return src
}
export function applyGlobalLocks(params: {
@@ -133,20 +169,21 @@ export function applyGlobalLocks(params: {
}) {
const beat = String(params.prompt || '').trim()
if (!beat) return beat
if (looksAlreadyInherited(beat)) return beat
const refs = normalizePermanenceRefs(params.refs)
const locks = String(params.locks || '').trim()
const blank = isBlankGlobalLocks(locks)
if (blank && !refs.length) return beat
if (params.shotIndex <= 0) {
const chunks = [
blank ? '' : locks,
permanenceLine(refs),
beat
].filter(Boolean)
return chunks.join('\n\n')
if (looksAlreadyInherited(beat)) {
const line = permanenceLine(refs)
if (line && !/Permanence:/i.test(beat)) return `${beat}\n\n${line}`
return beat
}
if (blank && !refs.length) return beat
return [blank ? '' : locks, permanenceLine(refs), beat].filter(Boolean).join('\n\n')
}
return `${inheritLine(blank ? '' : locks, refs)}\n\n${beat}`
const body = stripLockHeader(beat)
if (blank && !refs.length) return body
return `${inheritLine(refs)}\n\n${body}`
}
export function composeShotPrompt(params: {