diff --git a/components/ClipFrameThumbs.vue b/components/ClipFrameThumbs.vue index 64730e9..2ba6f46 100644 --- a/components/ClipFrameThumbs.vue +++ b/components/ClipFrameThumbs.vue @@ -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 '' } diff --git a/components/SettingsModal.vue b/components/SettingsModal.vue index 9ba56c3..1628c25 100644 --- a/components/SettingsModal.vue +++ b/components/SettingsModal.vue @@ -5,11 +5,24 @@

Settings

-

Library folders, passwords, and prompt-helper instruction sets for this account.

+

Library storage, folders, passwords, and prompt-helper instruction sets for this account.

+
+

Storage

+
+

Measuring…

+

{{ storageError }}

+ +
+
+

Site login

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 @@
-
@@ -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: {