Username and password are stored encrypted on this server. Changing them does not move the library.
@@ -207,6 +220,56 @@ const loginUser = ref(props.username || '')
const loginCurrent = ref('')
const loginNext = ref('')
const drafts = reactive>({})
+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()
diff --git a/pages/index.vue b/pages/index.vue
index 97bca69..8db1d09 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -290,8 +290,7 @@
@update-label="updateFamilyRefLabel"
/>
- 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.
{{ permanenceComfyNote }}
{{ familyRefLabelLine }}
@@ -515,18 +514,18 @@
-
+
Use identity references
- 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.
+ 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.
-
+
-
+
-
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.
+
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.
+
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.
@@ -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"
/>
@@ -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 | null = null
let editTimer: ReturnType | 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(`/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)
})
diff --git a/server/api/extend.post.ts b/server/api/extend.post.ts
index 3134d3c..1a53512 100644
--- a/server/api/extend.post.ts
+++ b/server/api/extend.post.ts
@@ -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)
diff --git a/server/api/generate.post.ts b/server/api/generate.post.ts
index d45f525..97747ff 100644
--- a/server/api/generate.post.ts
+++ b/server/api/generate.post.ts
@@ -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),
diff --git a/server/api/library/clips/[id]/first-frame.get.ts b/server/api/library/clips/[id]/first-frame.get.ts
index 0807f2b..01e01f0 100644
--- a/server/api/library/clips/[id]/first-frame.get.ts
+++ b/server/api/library/clips/[id]/first-frame.get.ts
@@ -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
diff --git a/server/api/library/clips/[id]/last-frame.get.ts b/server/api/library/clips/[id]/last-frame.get.ts
index 9110f3d..a492987 100644
--- a/server/api/library/clips/[id]/last-frame.get.ts
+++ b/server/api/library/clips/[id]/last-frame.get.ts
@@ -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
diff --git a/server/api/settings/storage.get.ts b/server/api/settings/storage.get.ts
new file mode 100644
index 0000000..5a521db
--- /dev/null
+++ b/server/api/settings/storage.get.ts
@@ -0,0 +1,4 @@
+export default defineEventHandler(async (event) => {
+ const { owner } = assertLibraryOwner(event)
+ return libraryStorage(owner)
+})
diff --git a/server/utils/ffmpeg.ts b/server/utils/ffmpeg.ts
index baab1ac..726c2c5 100644
--- a/server/utils/ffmpeg.ts
+++ b/server/utils/ffmpeg.ts
@@ -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 */ }
}
}
diff --git a/server/utils/library.ts b/server/utils/library.ts
index 9a3966a..2da52aa 100644
--- a/server/utils/library.ts
+++ b/server/utils/library.ts
@@ -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>()
-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 {
diff --git a/server/utils/pending.ts b/server/utils/pending.ts
index 30aa554..a23e00b 100644
--- a/server/utils/pending.ts
+++ b/server/utils/pending.ts
@@ -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,
diff --git a/server/utils/storage.ts b/server/utils/storage.ts
new file mode 100644
index 0000000..de39e62
--- /dev/null
+++ b/server/utils/storage.ts
@@ -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 | 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 | 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 {
+ 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 {
+ 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."
+ }
+}
diff --git a/server/utils/studioQueue.ts b/server/utils/studioQueue.ts
index e779404..53a7900 100644
--- a/server/utils/studioQueue.ts
+++ b/server/utils/studioQueue.ts
@@ -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)
diff --git a/server/utils/videoChain.ts b/server/utils/videoChain.ts
index 202f8be..27703d8 100644
--- a/server/utils/videoChain.ts
+++ b/server/utils/videoChain.ts
@@ -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,
diff --git a/server/utils/watch.ts b/server/utils/watch.ts
index 111093e..5006356 100644
--- a/server/utils/watch.ts
+++ b/server/utils/watch.ts
@@ -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): 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
diff --git a/utils/globalLocks.ts b/utils/globalLocks.ts
index 99f4139..42c7f29 100644
--- a/utils/globalLocks.ts
+++ b/utils/globalLocks.ts
@@ -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: {