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) { function frameThumb(key: SlotKey) {
if (concealed.value) return '' if (concealed.value) return ''
if (key === 'still' && props.stillId) return `/api/library/stills/${props.stillId}?thumb=1` 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 === '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` if (key === 'first' && props.clipId) return `/api/library/clips/${props.clipId}/first-frame?thumb=1&v=join`
return '' return ''
} }
function frameFull(key: SlotKey) { function frameFull(key: SlotKey) {
if (concealed.value) return '' if (concealed.value) return ''
if (key === 'still' && props.stillId) return `/api/library/stills/${props.stillId}` 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 === '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` if (key === 'first' && props.clipId) return `/api/library/clips/${props.clipId}/first-frame?v=join`
return '' return ''
} }
+78 -1
View File
@@ -5,11 +5,24 @@
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
<h2 class="font-display text-2xl font-bold">Settings</h2> <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> </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> <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> </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"> <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> <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> <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 loginCurrent = ref('')
const loginNext = ref('') const loginNext = ref('')
const drafts = reactive<Record<string, { name: string; protect: boolean; password: string; currentPassword: string }>>({}) 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) { function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') emit('close') if (event.key === 'Escape') emit('close')
@@ -215,6 +278,7 @@ function onKey(event: KeyboardEvent) {
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
void loadPresets() void loadPresets()
void loadStorage()
}) })
onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
@@ -280,6 +344,19 @@ async function loadPresets() {
promptPresets.value = data.presets || [] 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() { async function addPreset() {
const name = newPresetName.value.trim() const name = newPresetName.value.trim()
const systemPrompt = newPresetPrompt.value.trim() const systemPrompt = newPresetPrompt.value.trim()
+37 -18
View File
@@ -290,8 +290,7 @@
@update-label="updateFamilyRefLabel" @update-label="updateFamilyRefLabel"
/> />
<p class="mt-2 text-[11px] leading-relaxed text-zinc-500"> <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. MiniMax will not see these photos. Continuity is the last frame of the previous clip. The stills only name the lock lines.
The generate start still stays the first frame — attach it here only if you want a named alias.
</p> </p>
<p class="mt-1 text-[11px] leading-relaxed text-zinc-500">{{ permanenceComfyNote }}</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> <p v-if="familyRefLabelLine" class="mt-1 text-[11px] text-amber-200/80">{{ familyRefLabelLine }}</p>
@@ -515,18 +514,18 @@
</div> </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"> <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>
<span class="block font-medium text-zinc-200">Use identity references</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>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center"> <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 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 class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span> </span>
</label> </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 <div
v-for="slot in 4" v-for="slot in 4"
:key="slot" :key="slot"
@@ -564,7 +563,8 @@
</button> </button>
</div> </div>
</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>
</div> </div>
@@ -1244,9 +1244,9 @@
:hidden="item.hideThumbnail" :hidden="item.hideThumbnail"
:revealed="!!stitchReveal[item.id]" :revealed="!!stitchReveal[item.id]"
:still-id="partIndex === 0 ? item.stillId : undefined" :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" :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" @update:revealed="stitchReveal[item.id] = $event"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -1541,10 +1541,12 @@ import {
composeShotPrompt, composeShotPrompt,
extractGlobalLocks, extractGlobalLocks,
isBlankGlobalLocks, isBlankGlobalLocks,
mergePermanenceRefs,
nextPermanenceLabel, nextPermanenceLabel,
parseLockBindingKeys, parseLockBindingKeys,
permanenceLabels, permanenceLabels,
sanitizeRefLabel, sanitizeRefLabel,
shouldForceLastFrameI2V,
type PermanenceRef type PermanenceRef
} from '~/utils/globalLocks' } from '~/utils/globalLocks'
import { import {
@@ -1890,11 +1892,18 @@ const familyRefLabelLine = computed(() => {
if (!labels.length) return '' if (!labels.length) return ''
return `In the prompt: Permanence: ${labels.join(', ')} stay the same.` 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(() => { 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) { 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 '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 videoTimer: ReturnType<typeof setInterval> | null = null
let editTimer: 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 [] if (!recommendText.value || recommendBusy.value) return []
return parseShotScript(recommendText.value) 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] : [])) const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : []))
function composedShotPreview(text: string, index: number, shotN?: number) { function composedShotPreview(text: string, index: number, shotN?: number) {
const composed = composeShotPrompt({ 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 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[] }) { function collectionTitle(group: { head: LibraryClip; parts: LibraryClip[] }) {
@@ -2688,6 +2704,10 @@ watch(useIdentityRefs, (value) => {
} catch { /* ignore */ } } catch { /* ignore */ }
}) })
watch(extraPersonLock, (locked) => {
if (locked) useIdentityRefs.value = false
})
watch([globalLocks, familyPermanenceRefs, shotPermanenceRefs], () => { watch([globalLocks, familyPermanenceRefs, shotPermanenceRefs], () => {
try { try {
localStorage.setItem(LOCKS_STORE, globalLocks.value) 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 index = Math.max(0, parts.findIndex(part => part.id === item.id))
const isExtension = index > 0 || Boolean(item.parentClipId) || (item.chainIndex || 0) > 0 const isExtension = index > 0 || Boolean(item.parentClipId) || (item.chainIndex || 0) > 0
if (isExtension) { if (isExtension) {
const previous = index > 0 const previous = (item.parentClipId && clips.value.find(clip => clip.id === item.parentClipId))
? parts[index - 1] || (index > 0 ? parts[index - 1] : undefined)
: clips.value.find(clip => clip.id === item.parentClipId)
if (previous) { if (previous) {
const blob = await $fetch<Blob>(`/api/library/clips/${previous.id}/last-frame`, { responseType: 'blob' }) 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 }) 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('sound', String(withSound.value))
body.append('name', clipName.value.trim()) body.append('name', clipName.value.trim())
body.append('workflow', videoWorkflow.value) body.append('workflow', videoWorkflow.value)
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value)) body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value))
if (videoWorkflow.value === 'v2' && useIdentityRefs.value) { if (videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value) {
identityRefs.value.forEach((ref, index) => { identityRefs.value.forEach((ref, index) => {
if (ref) body.append(`refImage${index}`, ref) if (ref) body.append(`refImage${index}`, ref)
}) })
+4 -1
View File
@@ -172,8 +172,11 @@ async function runExtend(
} }
) { ) {
copyFileSync(params.sourcePath, params.part1Path) copyFileSync(params.sourcePath, params.part1Path)
await extractLastFrame(params.part1Path, params.framePath) await extractLastFrame(params.sourcePath, params.framePath)
const frame = readFileSync(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 if (job.library) job.library.thumb = undefined
const sound = await probeHasAudio(params.part1Path) const sound = await probeHasAudio(params.part1Path)
+3 -2
View File
@@ -1,6 +1,6 @@
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue' import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, parseVideoWorkflow } from '~/utils/videoModels' 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) { function parseDuration(raw: unknown) {
const seconds = Number(raw) const seconds = Number(raw)
@@ -121,7 +121,8 @@ export default defineEventHandler(async (event) => {
shotPermanenceRefs[index + 1] = item.permanenceRefs 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( const { width, height } = resolveOutputSize(
fields.aspect, fields.aspect,
image?.data || Buffer.alloc(0), image?.data || Buffer.alloc(0),
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
const etag = fileEtag(path) const etag = fileEtag(path)
setHeader(event, 'ETag', etag) 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) { if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304) setResponseStatus(event, 304)
return null return null
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
const etag = fileEtag(path) const etag = fileEtag(path)
setHeader(event, 'ETag', etag) 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) { if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304) setResponseStatus(event, 304)
return null 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, "'\\''") 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) { export async function extractLastFrame(inputPath: string, outputPath: string) {
if (!existsSync(inputPath)) { if (!existsSync(inputPath)) {
throw new Error('Source video is missing') throw new Error('Source video is missing')
@@ -80,7 +86,7 @@ export async function extractLastFrame(inputPath: string, outputPath: string) {
for (const args of attempts) { for (const args of attempts) {
try { try {
await runFfmpeg(args, 60000) await runFfmpeg(args, 60000)
if (existsSync(outputPath)) return if (existsSync(outputPath) && statSync(outputPath).size >= 64) return
} catch { } catch {
// Try the next seek strategy. // Try the next seek strategy.
} }
@@ -102,8 +108,38 @@ export async function extractFirstFrame(inputPath: string, outputPath: string) {
'-q:v', '4', '-q:v', '4',
outputPath outputPath
], 20000) ], 20000)
if (!existsSync(outputPath)) { assertFrameFile(outputPath, 'first frame')
throw new Error('Could not extract the 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 { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import type { H3Event } from 'h3' 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' import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
const scryptAsync = promisify(scrypt) const scryptAsync = promisify(scrypt)
@@ -122,7 +122,7 @@ interface Catalog {
const writeChains = new Map<string, Promise<unknown>>() const writeChains = new Map<string, Promise<unknown>>()
function libraryRoot() { export function libraryRoot() {
const config = useRuntimeConfig() const config = useRuntimeConfig()
return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '') 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) return createHash('sha256').update(raw).digest('hex').slice(0, 24)
} }
function ownerRoot(owner: string) { export function ownerRoot(owner: string) {
return join(libraryRoot(), 'users', owner) return join(libraryRoot(), 'users', owner)
} }
@@ -612,11 +612,69 @@ export async function ensureScaledThumb(source: string, dest: string) {
return build 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) { export async function ensureClipLastFrame(owner: string, id: string) {
const video = clipVideoPath(owner, id) const video = clipVideoPath(owner, id)
const dest = clipLastFramePath(owner, id) const dest = clipLastFramePath(owner, id)
if (!existsSync(video)) return '' 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 : '' return existsSync(dest) ? dest : ''
} }
@@ -624,8 +682,29 @@ export async function ensureClipFirstFrame(owner: string, id: string) {
const video = clipVideoPath(owner, id) const video = clipVideoPath(owner, id)
const dest = clipFirstFramePath(owner, id) const dest = clipFirstFramePath(owner, id)
if (!existsSync(video)) return '' if (!existsSync(video)) return ''
if (!existsSync(dest)) await extractFirstFrame(video, dest) const catalog = readCatalog(owner)
return existsSync(dest) ? dest : '' 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) { export function extendTempDir(owner: string, jobId: string) {
@@ -1313,6 +1392,7 @@ export async function saveClip(params: {
sound?: boolean sound?: boolean
globalLocks?: string globalLocks?: string
permanenceRefs?: PermanenceRef[] permanenceRefs?: PermanenceRef[]
segmentFirstFrame?: Buffer
}) { }) {
const catalog = readCatalog(params.ownerKey) const catalog = readCatalog(params.ownerKey)
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0] 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) const videoPath = clipVideoPath(params.ownerKey, clip.id)
writeFileSync(videoPath, params.video) writeFileSync(videoPath, params.video)
await remuxFaststart(videoPath) 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) { if (typeof params.duration === 'number' && params.duration > 0) {
clip.duration = params.duration clip.duration = params.duration
} else { } else {
+9 -1
View File
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import type { Job } from '~/server/utils/jobs' import type { Job } from '~/server/utils/jobs'
import { extractFirstFrameFromBuffer } from '~/server/utils/ffmpeg'
import { mergePermanenceRefs } from '~/utils/globalLocks' import { mergePermanenceRefs } from '~/utils/globalLocks'
export interface PendingJob { export interface PendingJob {
@@ -270,11 +271,17 @@ export async function completePendingIfReady(pending: PendingJob) {
if (inFlight()) return null if (inFlight()) return null
const afterDownload = alreadySaved() const afterDownload = alreadySaved()
if (afterDownload) return afterDownload if (afterDownload) return afterDownload
let segmentFirstFrame: Buffer | undefined
if (pending.extendPart1Path && pending.extendTmpDir) { if (pending.extendPart1Path && pending.extendTmpDir) {
if (!existsSync(pending.extendPart1Path)) { if (!existsSync(pending.extendPart1Path)) {
removeExtendTemp(pending.extendTmpDir) removeExtendTemp(pending.extendTmpDir)
throw createError({ statusCode: 500, statusMessage: 'Extension source clip was missing during stitch' }) 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 { try {
buffer = await stitchExtension({ buffer = await stitchExtension({
part1Path: pending.extendPart1Path, part1Path: pending.extendPart1Path,
@@ -310,7 +317,8 @@ export async function completePendingIfReady(pending: PendingJob) {
stillId: pending.stillId, stillId: pending.stillId,
sound: pending.sound, sound: pending.sound,
globalLocks: pending.globalLocks, globalLocks: pending.globalLocks,
permanenceRefs: mergePermanenceRefs(pending.permanenceRefs, pending.shotPermanenceRefs?.[pending.chainIndex || 0]) permanenceRefs: mergePermanenceRefs(pending.permanenceRefs, pending.shotPermanenceRefs?.[pending.chainIndex || 0]),
segmentFirstFrame
}) })
await purgeComfyArtifacts({ await purgeComfyArtifacts({
video, 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 { listPendingJobs, patchPendingJob, readPendingJob, deletePendingJob } from '~/server/utils/pending'
import { getShotQueue } from '~/server/utils/shotQueue' import { getShotQueue } from '~/server/utils/shotQueue'
import { parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels' 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 StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
export type StudioJobKind = 'video' | 'edit' export type StudioJobKind = 'video' | 'edit'
@@ -680,6 +680,12 @@ export async function startStudioJob(item: StudioJob) {
const { existsSync, readFileSync } = await import('node:fs') const { existsSync, readFileSync } = await import('node:fs')
const payload = item.payload const payload = item.payload
const identity = allowIdentityRefs(
payload.useIdentityRefs,
payload.permanenceRefs,
payload.globalLocks,
payload.shotPermanenceRefs
)
const job = createJob() const job = createJob()
job.kind = 'video' job.kind = 'video'
job.maxStep = payload.steps job.maxStep = payload.steps
@@ -715,7 +721,7 @@ export async function startStudioJob(item: StudioJob) {
chainLabel: extensions.length ? 'Initial' : undefined, chainLabel: extensions.length ? 'Initial' : undefined,
familyId: item.familyId, familyId: item.familyId,
workflow: parseVideoWorkflow(payload.workflow), workflow: parseVideoWorkflow(payload.workflow),
useIdentityRefs: payload.useIdentityRefs, useIdentityRefs: identity,
queueAutoRun: extensions.length ? payload.queueAutoRun : false, queueAutoRun: extensions.length ? payload.queueAutoRun : false,
queueBudget: extensions.length && payload.queueAutoRun ? extensions.length : 0, queueBudget: extensions.length && payload.queueAutoRun ? extensions.length : 0,
globalLocks: payload.globalLocks, globalLocks: payload.globalLocks,
@@ -745,7 +751,7 @@ export async function startStudioJob(item: StudioJob) {
scheduler: payload.scheduler, scheduler: payload.scheduler,
workflow: parseVideoWorkflow(payload.workflow), workflow: parseVideoWorkflow(payload.workflow),
sound: payload.sound, sound: payload.sound,
useIdentityRefs: payload.useIdentityRefs, useIdentityRefs: identity,
referenceStillIds: payload.referenceStillIds, referenceStillIds: payload.referenceStillIds,
globalLocks: payload.globalLocks, globalLocks: payload.globalLocks,
permanenceRefs: payload.permanenceRefs, permanenceRefs: payload.permanenceRefs,
@@ -798,7 +804,7 @@ export async function startStudioJob(item: StudioJob) {
extensions, extensions,
workflow: parseVideoWorkflow(payload.workflow), workflow: parseVideoWorkflow(payload.workflow),
duration: payload.duration, duration: payload.duration,
useIdentityRefs: payload.useIdentityRefs, useIdentityRefs: identity,
referenceImages referenceImages
}).catch((error) => { }).catch((error) => {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
+29 -7
View File
@@ -20,7 +20,7 @@ import {
setQueueJob, setQueueJob,
updateShotQueue updateShotQueue
} from '~/server/utils/shotQueue' } 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 } export type ChainImage = { filename: string; data: Buffer; type?: string }
@@ -110,7 +110,12 @@ function paramsFromJob(job: Job): VideoChainParams {
extensions: library.extensions || [], extensions: library.extensions || [],
workflow, workflow,
duration, duration,
useIdentityRefs: library.useIdentityRefs === true, useIdentityRefs: allowIdentityRefs(
library.useIdentityRefs === true,
library.permanenceRefs,
library.globalLocks,
library.shotPermanenceRefs
),
referenceImages, referenceImages,
globalLocks: library.globalLocks, globalLocks: library.globalLocks,
permanenceRefs: library.permanenceRefs, permanenceRefs: library.permanenceRefs,
@@ -336,8 +341,11 @@ export async function continueQueuedExtensions(
} }
copyFileSync(currentPath, part1Path) copyFileSync(currentPath, part1Path)
const parentId = job.library.parentClipId
const parentVideo = parentId ? clipVideoPath(job.library.ownerKey, parentId) : ''
const extractSource = parentVideo && existsSync(parentVideo) ? parentVideo : part1Path
try { try {
await extractLastFrame(part1Path, framePath) await extractLastFrame(extractSource, framePath)
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : String(error) const detail = error instanceof Error ? error.message : String(error)
throw new Error(detail.includes('last frame') 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}`) : `Could not extract the last frame for the next extension: ${detail}`)
} }
const frame = readFileSync(framePath) 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.extendPart1Path = part1Path
job.library.prompt = ext.prompt job.library.prompt = ext.prompt
if (live.permanenceRefs?.length) { if (live.permanenceRefs?.length) {
@@ -354,11 +365,17 @@ export async function continueQueuedExtensions(
} }
const sound = await probeHasAudio(currentPath) const sound = await probeHasAudio(currentPath)
const seed = Math.floor(Math.random() * 2_147_483_647) 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 ensureComfyReady(ready)
await queueMiniMax(job, { await queueMiniMax(job, {
prompt: ext.prompt, prompt: ext.prompt,
image: params.useIdentityRefs image: identity
? params.image ? params.image
: { filename: 'last_frame.png', data: frame, type: 'image/png' }, : { filename: 'last_frame.png', data: frame, type: 'image/png' },
width: params.width, width: params.width,
@@ -375,8 +392,8 @@ export async function continueQueuedExtensions(
persist, persist,
workflow: workflowForExtension(params.workflow), workflow: workflowForExtension(params.workflow),
duration: ext.duration, duration: ext.duration,
useIdentityRefs: params.useIdentityRefs, useIdentityRefs: identity,
referenceImages: params.useIdentityRefs ? params.referenceImages : [] referenceImages: identity ? params.referenceImages : []
}) })
shotsLeft -= 1 shotsLeft -= 1
@@ -520,7 +537,12 @@ export async function startQueueBurst(owner: string, queueId: string, count: num
familyId: queue.familyId, familyId: queue.familyId,
parentClipId: clipId, parentClipId: clipId,
workflow: queue.workflow, workflow: queue.workflow,
useIdentityRefs: queue.useIdentityRefs, useIdentityRefs: allowIdentityRefs(
queue.useIdentityRefs === true,
queue.permanenceRefs,
queue.globalLocks,
queue.segments.map(segment => segment.permanenceRefs || [])
),
queueId: queue.id, queueId: queue.id,
queueAutoRun: count === 'all', queueAutoRun: count === 'all',
queueBudget: n, 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 { isLastChainShot, libraryFromPending, pendingFromJob, remainingAfterCurrentShot, writePendingJob, deletePendingJob } from '~/server/utils/pending'
import { syncQueueFromJob } from '~/server/utils/shotQueue' import { syncQueueFromJob } from '~/server/utils/shotQueue'
import { isEncodingNode, NODE_LABELS } from '~/server/utils/workflow' import { isEncodingNode, NODE_LABELS } from '~/server/utils/workflow'
import { extractFirstFrameFromBuffer } from '~/server/utils/ffmpeg'
import { mergePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks' import { mergePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
function clipRefsFromJob(library: NonNullable<Job['library']>): PermanenceRef[] | undefined { function clipRefsFromJob(library: NonNullable<Job['library']>): PermanenceRef[] | undefined {
@@ -186,10 +187,18 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
}) })
if (job.library) { if (job.library) {
let buffer = await downloadComfyVideo(video) let buffer = await downloadComfyVideo(video)
let segmentFirstFrame: Buffer | undefined
if (stitching) { if (stitching) {
if (!existsSync(job.library.extendPart1Path!)) { if (!existsSync(job.library.extendPart1Path!)) {
throw new Error('Extension source clip was missing during stitch') 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({ buffer = await stitchExtension({
part1Path: job.library.extendPart1Path!, part1Path: job.library.extendPart1Path!,
part2: buffer, part2: buffer,
@@ -225,7 +234,8 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
stillId: job.library.stillId, stillId: job.library.stillId,
sound: job.library.sound, sound: job.library.sound,
globalLocks: job.library.globalLocks, globalLocks: job.library.globalLocks,
permanenceRefs: clipRefsFromJob(job.library) permanenceRefs: clipRefsFromJob(job.library),
segmentFirstFrame
}) })
job.clipId = clip.id job.clipId = clip.id
job.savedPromptId = job.promptId job.savedPromptId = job.promptId
+57 -20
View File
@@ -101,28 +101,64 @@ export function resolveGlobalLocks(locks: string, firstPrompt: string) {
: { locks: '', prompt: firstPrompt } : { 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) { export function permanenceLabels(refs: PermanenceRef[] | null | undefined) {
return [...new Set(normalizePermanenceRefs(refs).map(item => item.label).filter(Boolean))] 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[]) { function permanenceLine(refs: PermanenceRef[]) {
const labels = permanenceLabels(refs) const labels = permanenceLabels(refs)
if (!labels.length) return '' if (!labels.length) return ''
return `Permanence: ${labels.join(', ')} stay the same.` return `Permanence: ${labels.join(', ')} stay the same.`
} }
function inheritLine(locks: string, refs: PermanenceRef[]) { function inheritLine(refs: PermanenceRef[]) {
const parts = ['[INHERITS] Global locks.']
const labels = permanenceLabels(refs) const labels = permanenceLabels(refs)
if (labels.length) parts.push(`Same ${labels.join(', ')} as locked.`) if (labels.length) return `[INHERITS] Same ${labels.join(', ')} as now`
const state = extractState(locks) return '[INHERITS] Same as now'
if (state) parts.push(`State: ${state}`) }
return parts.join(' ')
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: { export function applyGlobalLocks(params: {
@@ -133,20 +169,21 @@ export function applyGlobalLocks(params: {
}) { }) {
const beat = String(params.prompt || '').trim() const beat = String(params.prompt || '').trim()
if (!beat) return beat if (!beat) return beat
if (looksAlreadyInherited(beat)) return beat
const refs = normalizePermanenceRefs(params.refs) const refs = normalizePermanenceRefs(params.refs)
const locks = String(params.locks || '').trim() const locks = String(params.locks || '').trim()
const blank = isBlankGlobalLocks(locks) const blank = isBlankGlobalLocks(locks)
if (blank && !refs.length) return beat
if (params.shotIndex <= 0) { if (params.shotIndex <= 0) {
const chunks = [ if (looksAlreadyInherited(beat)) {
blank ? '' : locks, const line = permanenceLine(refs)
permanenceLine(refs), if (line && !/Permanence:/i.test(beat)) return `${beat}\n\n${line}`
beat return beat
].filter(Boolean)
return chunks.join('\n\n')
} }
return `${inheritLine(blank ? '' : locks, refs)}\n\n${beat}` if (blank && !refs.length) return beat
return [blank ? '' : locks, permanenceLine(refs), beat].filter(Boolean).join('\n\n')
}
const body = stripLockHeader(beat)
if (blank && !refs.length) return body
return `${inheritLine(refs)}\n\n${body}`
} }
export function composeShotPrompt(params: { export function composeShotPrompt(params: {