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:
@@ -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 ''
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,24 @@
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-display text-2xl font-bold">Settings</h2>
|
||||
<p class="mt-1 text-sm text-zinc-400">Library folders, passwords, and prompt-helper instruction sets for this account.</p>
|
||||
<p class="mt-1 text-sm text-zinc-400">Library storage, folders, passwords, and prompt-helper instruction sets for this account.</p>
|
||||
</div>
|
||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="emit('close')">Close</button>
|
||||
</div>
|
||||
|
||||
<section class="mt-6">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Storage</h3>
|
||||
<div class="mt-2 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
|
||||
<p v-if="storageLoading" class="text-sm text-zinc-500">Measuring…</p>
|
||||
<p v-else-if="storageError" class="text-sm text-zinc-500">{{ storageError }}</p>
|
||||
<template v-else-if="storage">
|
||||
<p class="text-sm text-zinc-200">{{ storageLine }}</p>
|
||||
<p v-if="storageBreakdown" class="mt-1 text-xs text-zinc-500">{{ storageBreakdown }}</p>
|
||||
<p class="mt-1 text-xs text-zinc-600">{{ storageNote }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="authMode === 'password'" class="mt-6 space-y-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Site login</h3>
|
||||
<p class="text-sm text-zinc-400">Username and password are stored encrypted on this server. Changing them does not move the library.</p>
|
||||
@@ -207,6 +220,56 @@ const loginUser = ref(props.username || '')
|
||||
const loginCurrent = ref('')
|
||||
const loginNext = ref('')
|
||||
const drafts = reactive<Record<string, { name: string; protect: boolean; password: string; currentPassword: string }>>({})
|
||||
const storageLoading = ref(true)
|
||||
const storageError = ref('')
|
||||
const storage = ref<{
|
||||
libraryBytes: number
|
||||
breakdown: { clips: number; stills: number; tmp: number; other: number }
|
||||
volume: { ok: boolean; totalBytes: number | null; usedBytes: number | null; freeBytes: number | null; overlay: boolean }
|
||||
} | null>(null)
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
const n = Math.max(0, Number(bytes) || 0)
|
||||
if (n < 1024) return `${Math.round(n)} B`
|
||||
const units = ['KB', 'MB', 'GB', 'TB']
|
||||
let value = n / 1024
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
const digits = value >= 10 || units[unit] === 'KB' ? 0 : 1
|
||||
return `${value.toFixed(digits)} ${units[unit]}`
|
||||
}
|
||||
|
||||
const storageLine = computed(() => {
|
||||
if (!storage.value) return ''
|
||||
const library = `Library ${formatBytes(storage.value.libraryBytes)}`
|
||||
const volume = storage.value.volume
|
||||
if (!volume.ok || volume.freeBytes == null || volume.totalBytes == null) {
|
||||
return `${library} · free space unavailable`
|
||||
}
|
||||
const free = `${formatBytes(volume.freeBytes)} free of ${formatBytes(volume.totalBytes)}`
|
||||
return volume.overlay ? `${library} · ${free} on the container disk` : `${library} · ${free}`
|
||||
})
|
||||
|
||||
const storageBreakdown = computed(() => {
|
||||
if (!storage.value) return ''
|
||||
const { clips, stills, tmp } = storage.value.breakdown
|
||||
const parts = [
|
||||
clips ? `Clips ${formatBytes(clips)}` : '',
|
||||
stills ? `Stills ${formatBytes(stills)}` : '',
|
||||
tmp ? `Temp ${formatBytes(tmp)}` : ''
|
||||
].filter(Boolean)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
const storageNote = computed(() => {
|
||||
if (!storage.value) return ''
|
||||
return storage.value.volume.overlay
|
||||
? "This account's clips, stills, thumbs, and temp. Free space is the container disk, not a mounted volume."
|
||||
: "This account's clips, stills, thumbs, and temp. Volume free space is shared."
|
||||
})
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') emit('close')
|
||||
@@ -215,6 +278,7 @@ function onKey(event: KeyboardEvent) {
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKey)
|
||||
void loadPresets()
|
||||
void loadStorage()
|
||||
})
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||
|
||||
@@ -280,6 +344,19 @@ async function loadPresets() {
|
||||
promptPresets.value = data.presets || []
|
||||
}
|
||||
|
||||
async function loadStorage() {
|
||||
storageLoading.value = true
|
||||
storageError.value = ''
|
||||
try {
|
||||
storage.value = await $fetch('/api/settings/storage')
|
||||
} catch (error: any) {
|
||||
storage.value = null
|
||||
storageError.value = error?.data?.statusMessage || error?.statusMessage || 'Could not read storage'
|
||||
} finally {
|
||||
storageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addPreset() {
|
||||
const name = newPresetName.value.trim()
|
||||
const systemPrompt = newPresetPrompt.value.trim()
|
||||
|
||||
+37
-18
@@ -290,8 +290,7 @@
|
||||
@update-label="updateFamilyRefLabel"
|
||||
/>
|
||||
<p class="mt-2 text-[11px] leading-relaxed text-zinc-500">
|
||||
Name each still, or pick a GLOBAL LOCKS key. Shot prompts inherit those names.
|
||||
The generate start still stays the first frame — attach it here only if you want a named alias.
|
||||
MiniMax will not see these photos. Continuity is the last frame of the previous clip. The stills only name the lock lines.
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] leading-relaxed text-zinc-500">{{ permanenceComfyNote }}</p>
|
||||
<p v-if="familyRefLabelLine" class="mt-1 text-[11px] text-amber-200/80">{{ familyRefLabelLine }}</p>
|
||||
@@ -515,18 +514,18 @@
|
||||
</div>
|
||||
|
||||
<div v-if="videoEngine === 'minimax' && videoStart === 'still' && minimaxGraph === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 space-y-3">
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm">
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm" :class="extraPersonLock ? 'cursor-not-allowed opacity-60' : ''">
|
||||
<span>
|
||||
<span class="block font-medium text-zinc-200">Use identity references</span>
|
||||
<span class="mt-0.5 block text-xs text-zinc-500">Off uses the start still as first-frame only. On keeps that still as Picture 1. Extra stills are optional identity views of the same person, not extra characters.</span>
|
||||
<span class="mt-0.5 block text-xs text-zinc-500">Off uses last-frame I2V after shot 1. On keeps the start still as Picture 1 — extra stills are more views of that same person, never a second character. A named extra person forces last-frame I2V.</span>
|
||||
</span>
|
||||
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
||||
<input v-model="useIdentityRefs" type="checkbox" class="peer sr-only" role="switch">
|
||||
<input v-model="useIdentityRefs" type="checkbox" class="peer sr-only" role="switch" :disabled="extraPersonLock">
|
||||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||||
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="useIdentityRefs" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div v-if="useIdentityRefs && !extraPersonLock" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div
|
||||
v-for="slot in 4"
|
||||
:key="slot"
|
||||
@@ -564,7 +563,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="useIdentityRefs" class="text-xs text-zinc-500">Picture 1 is the start still and stays Picture 1 on every shot, including queued extensions. Extra stills map to Picture 2–5 only if you add them — extra views of that same person, not a second character or a vehicle. Empty slots are left out of the graph, not filled with copies of Picture 1.</p>
|
||||
<p v-if="extraPersonLock" class="text-xs text-zinc-500">Identity refs stay off while a second named person is attached. MiniMax will not see the extra stills; shot 2+ starts from the parent clip’s last frame.</p>
|
||||
<p v-else-if="useIdentityRefs" class="text-xs text-zinc-500">Picture 1 is the start still and stays Picture 1 on every shot, including queued extensions. Extra stills map to Picture 2–5 only if you add them — extra views of that same person, not a second character or a vehicle. Empty slots are left out of the graph, not filled with copies of Picture 1.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1244,9 +1244,9 @@
|
||||
:hidden="item.hideThumbnail"
|
||||
:revealed="!!stitchReveal[item.id]"
|
||||
:still-id="partIndex === 0 ? item.stillId : undefined"
|
||||
:from-clip-id="previousChronoClipId(entry.group.parts, partIndex)"
|
||||
:from-clip-id="previousClipIdFor(item, entry.group.parts, partIndex)"
|
||||
:clip-id="item.id"
|
||||
:show-first-frame="partIndex > 0 || !item.stillId"
|
||||
:show-first-frame="partIndex > 0 || !item.stillId || Boolean(item.parentClipId)"
|
||||
@update:revealed="stitchReveal[item.id] = $event"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -1541,10 +1541,12 @@ import {
|
||||
composeShotPrompt,
|
||||
extractGlobalLocks,
|
||||
isBlankGlobalLocks,
|
||||
mergePermanenceRefs,
|
||||
nextPermanenceLabel,
|
||||
parseLockBindingKeys,
|
||||
permanenceLabels,
|
||||
sanitizeRefLabel,
|
||||
shouldForceLastFrameI2V,
|
||||
type PermanenceRef
|
||||
} from '~/utils/globalLocks'
|
||||
import {
|
||||
@@ -1890,11 +1892,18 @@ const familyRefLabelLine = computed(() => {
|
||||
if (!labels.length) return ''
|
||||
return `In the prompt: Permanence: ${labels.join(', ')} stay the same.`
|
||||
})
|
||||
const extraPersonLock = computed(() => shouldForceLastFrameI2V(
|
||||
mergePermanenceRefs(familyPermanenceRefs.value, Object.values(shotPermanenceRefs.value).flat()),
|
||||
globalLocks.value
|
||||
))
|
||||
const permanenceComfyNote = computed(() => {
|
||||
if (extraPersonLock.value) {
|
||||
return 'A second named person keeps last-frame I2V on. MiniMax never sees the man/bench photos; those names only go in the inherit line.'
|
||||
}
|
||||
if (studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value) {
|
||||
return 'Identity-ref still only maps extra views of the start-still person, never a second character. Permanence stills name the lock text; they are not Picture 2–5.'
|
||||
}
|
||||
return 'Last-frame I2V does not send these stills to MiniMax as extra pictures; they name what the lock text is talking about. Identity-ref still only maps extra views of the start-still person, never a second character.'
|
||||
return 'Last-frame I2V does not send these stills to MiniMax as extra pictures. Continuity is the previous clip’s last frame; the names only go in the lock line.'
|
||||
})
|
||||
let videoTimer: ReturnType<typeof setInterval> | null = null
|
||||
let editTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -2117,7 +2126,7 @@ const recommendedShots = computed(() => {
|
||||
if (!recommendText.value || recommendBusy.value) return []
|
||||
return parseShotScript(recommendText.value)
|
||||
})
|
||||
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value)
|
||||
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value)
|
||||
const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : []))
|
||||
function composedShotPreview(text: string, index: number, shotN?: number) {
|
||||
const composed = composeShotPrompt({
|
||||
@@ -2322,9 +2331,16 @@ function chronologicalParts(parts: LibraryClip[]) {
|
||||
})
|
||||
}
|
||||
|
||||
function previousChronoClipId(parts: LibraryClip[], index: number) {
|
||||
function previousClipIdFor(item: LibraryClip, parts: LibraryClip[], index: number) {
|
||||
if (item.parentClipId) return item.parentClipId
|
||||
if (index <= 0) return undefined
|
||||
return chronologicalParts(parts)[index - 1]?.id
|
||||
const ordered = chronologicalParts(parts)
|
||||
const current = ordered[index]
|
||||
const currentChain = current?.chainIndex || 0
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
if ((ordered[i].chainIndex || 0) < currentChain) return ordered[i].id
|
||||
}
|
||||
return ordered[index - 1]?.id
|
||||
}
|
||||
|
||||
function collectionTitle(group: { head: LibraryClip; parts: LibraryClip[] }) {
|
||||
@@ -2688,6 +2704,10 @@ watch(useIdentityRefs, (value) => {
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
watch(extraPersonLock, (locked) => {
|
||||
if (locked) useIdentityRefs.value = false
|
||||
})
|
||||
|
||||
watch([globalLocks, familyPermanenceRefs, shotPermanenceRefs], () => {
|
||||
try {
|
||||
localStorage.setItem(LOCKS_STORE, globalLocks.value)
|
||||
@@ -3502,9 +3522,8 @@ async function loadRerunImage(item: LibraryClip, parts: LibraryClip[]) {
|
||||
const index = Math.max(0, parts.findIndex(part => part.id === item.id))
|
||||
const isExtension = index > 0 || Boolean(item.parentClipId) || (item.chainIndex || 0) > 0
|
||||
if (isExtension) {
|
||||
const previous = index > 0
|
||||
? parts[index - 1]
|
||||
: clips.value.find(clip => clip.id === item.parentClipId)
|
||||
const previous = (item.parentClipId && clips.value.find(clip => clip.id === item.parentClipId))
|
||||
|| (index > 0 ? parts[index - 1] : undefined)
|
||||
if (previous) {
|
||||
const blob = await $fetch<Blob>(`/api/library/clips/${previous.id}/last-frame`, { responseType: 'blob' })
|
||||
readFile(new File([blob], 'last_frame.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true })
|
||||
@@ -4401,8 +4420,8 @@ async function generate() {
|
||||
body.append('sound', String(withSound.value))
|
||||
body.append('name', clipName.value.trim())
|
||||
body.append('workflow', videoWorkflow.value)
|
||||
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value))
|
||||
if (videoWorkflow.value === 'v2' && useIdentityRefs.value) {
|
||||
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value))
|
||||
if (videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value) {
|
||||
identityRefs.value.forEach((ref, index) => {
|
||||
if (ref) body.append(`refImage${index}`, ref)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
|
||||
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, parseVideoWorkflow } from '~/utils/videoModels'
|
||||
import { normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
|
||||
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
|
||||
|
||||
function parseDuration(raw: unknown) {
|
||||
const seconds = Number(raw)
|
||||
@@ -121,7 +121,8 @@ export default defineEventHandler(async (event) => {
|
||||
shotPermanenceRefs[index + 1] = item.permanenceRefs
|
||||
}
|
||||
}
|
||||
const useIdentityRefs = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
|
||||
const requestedIdentity = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
|
||||
const useIdentityRefs = allowIdentityRefs(requestedIdentity, permanenceRefs, globalLocks, shotPermanenceRefs)
|
||||
const { width, height } = resolveOutputSize(
|
||||
fields.aspect,
|
||||
image?.data || Buffer.alloc(0),
|
||||
|
||||
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const etag = fileEtag(path)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
|
||||
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=60, must-revalidate' : 'private, max-age=60, must-revalidate')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
|
||||
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const etag = fileEtag(path)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
|
||||
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=60, must-revalidate' : 'private, max-age=60, must-revalidate')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
return libraryStorage(owner)
|
||||
})
|
||||
+39
-3
@@ -59,6 +59,12 @@ function concatPath(filePath: string) {
|
||||
return filePath.replace(/\\/g, '/').replace(/'/g, "'\\''")
|
||||
}
|
||||
|
||||
function assertFrameFile(path: string, kind: string) {
|
||||
if (!existsSync(path) || statSync(path).size < 64) {
|
||||
throw new Error(`Could not extract the ${kind}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractLastFrame(inputPath: string, outputPath: string) {
|
||||
if (!existsSync(inputPath)) {
|
||||
throw new Error('Source video is missing')
|
||||
@@ -80,7 +86,7 @@ export async function extractLastFrame(inputPath: string, outputPath: string) {
|
||||
for (const args of attempts) {
|
||||
try {
|
||||
await runFfmpeg(args, 60000)
|
||||
if (existsSync(outputPath)) return
|
||||
if (existsSync(outputPath) && statSync(outputPath).size >= 64) return
|
||||
} catch {
|
||||
// Try the next seek strategy.
|
||||
}
|
||||
@@ -102,8 +108,38 @@ export async function extractFirstFrame(inputPath: string, outputPath: string) {
|
||||
'-q:v', '4',
|
||||
outputPath
|
||||
], 20000)
|
||||
if (!existsSync(outputPath)) {
|
||||
throw new Error('Could not extract the first frame')
|
||||
assertFrameFile(outputPath, 'first frame')
|
||||
}
|
||||
|
||||
export async function extractFrameAt(inputPath: string, outputPath: string, seconds: number) {
|
||||
if (!existsSync(inputPath)) {
|
||||
throw new Error('Source video is missing')
|
||||
}
|
||||
const seek = Math.max(0, seconds)
|
||||
await runFfmpeg([
|
||||
'-y',
|
||||
'-ss', seek.toFixed(3),
|
||||
'-i', inputPath,
|
||||
'-frames:v', '1',
|
||||
'-update', '1',
|
||||
'-an',
|
||||
'-q:v', '2',
|
||||
outputPath
|
||||
], 60000)
|
||||
assertFrameFile(outputPath, 'frame')
|
||||
}
|
||||
|
||||
export async function extractFirstFrameFromBuffer(video: Buffer, tmpDir: string) {
|
||||
if (!video.length) throw new Error('Could not extract the first frame')
|
||||
const input = join(tmpDir, `seg_head_${Date.now()}.mp4`)
|
||||
const output = join(tmpDir, `seg_first_${Date.now()}.jpg`)
|
||||
writeFileSync(input, video)
|
||||
try {
|
||||
await extractFirstFrame(input, output)
|
||||
return readFileSync(output)
|
||||
} finally {
|
||||
try { unlinkSync(input) } catch { /* ignore */ }
|
||||
try { unlinkSync(output) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+91
-6
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process'
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
||||
import { promisify } from 'node:util'
|
||||
import type { H3Event } from 'h3'
|
||||
import { extractFirstFrame, extractLastFrame, remuxFaststart } from '~/server/utils/ffmpeg'
|
||||
import { extractFirstFrame, extractFrameAt, extractLastFrame, probeDuration, remuxFaststart } from '~/server/utils/ffmpeg'
|
||||
import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||
|
||||
const scryptAsync = promisify(scrypt)
|
||||
@@ -122,7 +122,7 @@ interface Catalog {
|
||||
|
||||
const writeChains = new Map<string, Promise<unknown>>()
|
||||
|
||||
function libraryRoot() {
|
||||
export function libraryRoot() {
|
||||
const config = useRuntimeConfig()
|
||||
return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '')
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export function libraryOwnerKey(event: H3Event) {
|
||||
return createHash('sha256').update(raw).digest('hex').slice(0, 24)
|
||||
}
|
||||
|
||||
function ownerRoot(owner: string) {
|
||||
export function ownerRoot(owner: string) {
|
||||
return join(libraryRoot(), 'users', owner)
|
||||
}
|
||||
|
||||
@@ -612,11 +612,69 @@ export async function ensureScaledThumb(source: string, dest: string) {
|
||||
return build
|
||||
}
|
||||
|
||||
type FrameSourceMeta = {
|
||||
videoMtime: number
|
||||
videoSize: number
|
||||
mode: 'file-start' | 'segment-start' | 'last'
|
||||
seek?: number
|
||||
}
|
||||
|
||||
function frameMetaPath(dest: string) {
|
||||
return `${dest}.src.json`
|
||||
}
|
||||
|
||||
function readFrameMeta(dest: string): FrameSourceMeta | null {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(frameMetaPath(dest), 'utf8')) as FrameSourceMeta
|
||||
if (!raw || typeof raw.videoMtime !== 'number' || typeof raw.videoSize !== 'number') return null
|
||||
return raw
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeFrameMeta(dest: string, video: string, mode: FrameSourceMeta['mode'], seek?: number) {
|
||||
try {
|
||||
const info = statSync(video)
|
||||
const meta: FrameSourceMeta = { videoMtime: info.mtimeMs, videoSize: info.size, mode }
|
||||
if (typeof seek === 'number') meta.seek = seek
|
||||
writeFileSync(frameMetaPath(dest), JSON.stringify(meta))
|
||||
} catch { /* thumbs still work without the sidecar */ }
|
||||
}
|
||||
|
||||
function frameCacheFresh(video: string, dest: string, mode: FrameSourceMeta['mode']) {
|
||||
if (!existsSync(dest) || statSync(dest).size < 64) return false
|
||||
try {
|
||||
const info = statSync(video)
|
||||
const meta = readFrameMeta(dest)
|
||||
if (meta) {
|
||||
return meta.mode === mode && meta.videoMtime === info.mtimeMs && meta.videoSize === info.size
|
||||
}
|
||||
return statSync(dest).mtimeMs >= info.mtimeMs && mode === 'file-start'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function parentVideoDuration(owner: string, parentId?: string) {
|
||||
if (!parentId) return 0
|
||||
const video = clipVideoPath(owner, parentId)
|
||||
if (!existsSync(video)) return 0
|
||||
try {
|
||||
return await probeDuration(video)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureClipLastFrame(owner: string, id: string) {
|
||||
const video = clipVideoPath(owner, id)
|
||||
const dest = clipLastFramePath(owner, id)
|
||||
if (!existsSync(video)) return ''
|
||||
if (!existsSync(dest)) await extractLastFrame(video, dest)
|
||||
if (!frameCacheFresh(video, dest, 'last')) {
|
||||
await extractLastFrame(video, dest)
|
||||
writeFrameMeta(dest, video, 'last')
|
||||
}
|
||||
return existsSync(dest) ? dest : ''
|
||||
}
|
||||
|
||||
@@ -624,8 +682,29 @@ export async function ensureClipFirstFrame(owner: string, id: string) {
|
||||
const video = clipVideoPath(owner, id)
|
||||
const dest = clipFirstFramePath(owner, id)
|
||||
if (!existsSync(video)) return ''
|
||||
if (!existsSync(dest)) await extractFirstFrame(video, dest)
|
||||
return existsSync(dest) ? dest : ''
|
||||
const catalog = readCatalog(owner)
|
||||
const clip = catalog.clips.find(item => item.id === id)
|
||||
const parentDur = await parentVideoDuration(owner, clip?.parentClipId)
|
||||
let fileDur = 0
|
||||
try {
|
||||
fileDur = await probeDuration(video)
|
||||
} catch { /* treat as a single segment */ }
|
||||
const stored = Number(clip?.duration) || 0
|
||||
const stitched = (parentDur > 0.5 && fileDur > parentDur + 0.4)
|
||||
|| (stored > 0.5 && fileDur > stored + 0.8)
|
||||
const seek = parentDur > 0.5 && fileDur > parentDur + 0.4
|
||||
? parentDur
|
||||
: (stitched && stored > 0.5 ? Math.max(0, fileDur - stored) : 0)
|
||||
const mode: FrameSourceMeta['mode'] = stitched ? 'segment-start' : 'file-start'
|
||||
if (frameCacheFresh(video, dest, mode)) return dest
|
||||
if (stitched) {
|
||||
await extractFrameAt(video, dest, seek)
|
||||
writeFrameMeta(dest, video, 'segment-start', seek)
|
||||
return dest
|
||||
}
|
||||
await extractFirstFrame(video, dest)
|
||||
writeFrameMeta(dest, video, 'file-start', 0)
|
||||
return dest
|
||||
}
|
||||
|
||||
export function extendTempDir(owner: string, jobId: string) {
|
||||
@@ -1313,6 +1392,7 @@ export async function saveClip(params: {
|
||||
sound?: boolean
|
||||
globalLocks?: string
|
||||
permanenceRefs?: PermanenceRef[]
|
||||
segmentFirstFrame?: Buffer
|
||||
}) {
|
||||
const catalog = readCatalog(params.ownerKey)
|
||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||
@@ -1349,6 +1429,11 @@ export async function saveClip(params: {
|
||||
const videoPath = clipVideoPath(params.ownerKey, clip.id)
|
||||
writeFileSync(videoPath, params.video)
|
||||
await remuxFaststart(videoPath)
|
||||
if (params.segmentFirstFrame?.length && params.segmentFirstFrame.length >= 64) {
|
||||
const firstPath = clipFirstFramePath(params.ownerKey, clip.id)
|
||||
writeFileSync(firstPath, params.segmentFirstFrame)
|
||||
writeFrameMeta(firstPath, videoPath, 'segment-start')
|
||||
}
|
||||
if (typeof params.duration === 'number' && params.duration > 0) {
|
||||
clip.duration = params.duration
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { Job } from '~/server/utils/jobs'
|
||||
import { extractFirstFrameFromBuffer } from '~/server/utils/ffmpeg'
|
||||
import { mergePermanenceRefs } from '~/utils/globalLocks'
|
||||
|
||||
export interface PendingJob {
|
||||
@@ -270,11 +271,17 @@ export async function completePendingIfReady(pending: PendingJob) {
|
||||
if (inFlight()) return null
|
||||
const afterDownload = alreadySaved()
|
||||
if (afterDownload) return afterDownload
|
||||
let segmentFirstFrame: Buffer | undefined
|
||||
if (pending.extendPart1Path && pending.extendTmpDir) {
|
||||
if (!existsSync(pending.extendPart1Path)) {
|
||||
removeExtendTemp(pending.extendTmpDir)
|
||||
throw createError({ statusCode: 500, statusMessage: 'Extension source clip was missing during stitch' })
|
||||
}
|
||||
try {
|
||||
segmentFirstFrame = await extractFirstFrameFromBuffer(buffer, pending.extendTmpDir)
|
||||
} catch {
|
||||
// ensureClipFirstFrame will seek to the join if this frame is missing
|
||||
}
|
||||
try {
|
||||
buffer = await stitchExtension({
|
||||
part1Path: pending.extendPart1Path,
|
||||
@@ -310,7 +317,8 @@ export async function completePendingIfReady(pending: PendingJob) {
|
||||
stillId: pending.stillId,
|
||||
sound: pending.sound,
|
||||
globalLocks: pending.globalLocks,
|
||||
permanenceRefs: mergePermanenceRefs(pending.permanenceRefs, pending.shotPermanenceRefs?.[pending.chainIndex || 0])
|
||||
permanenceRefs: mergePermanenceRefs(pending.permanenceRefs, pending.shotPermanenceRefs?.[pending.chainIndex || 0]),
|
||||
segmentFirstFrame
|
||||
})
|
||||
await purgeComfyArtifacts({
|
||||
video,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
||||
import { statfs } from 'node:fs/promises'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { libraryRoot, ownerRoot } from '~/server/utils/library'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const OVERLAY_TYPES = new Set(['overlay', 'aufs', 'fuse-overlayfs', 'overlayfs'])
|
||||
|
||||
export interface StorageBreakdown {
|
||||
clips: number
|
||||
stills: number
|
||||
tmp: number
|
||||
other: number
|
||||
}
|
||||
|
||||
export interface VolumeStats {
|
||||
ok: boolean
|
||||
totalBytes: number | null
|
||||
usedBytes: number | null
|
||||
freeBytes: number | null
|
||||
overlay: boolean
|
||||
}
|
||||
|
||||
export interface LibraryStorage {
|
||||
libraryBytes: number
|
||||
breakdown: StorageBreakdown
|
||||
volume: VolumeStats
|
||||
counted: string
|
||||
}
|
||||
|
||||
function emptyBreakdown(): StorageBreakdown {
|
||||
return { clips: 0, stills: 0, tmp: 0, other: 0 }
|
||||
}
|
||||
|
||||
function bucketFor(prefix: string): keyof StorageBreakdown {
|
||||
if (prefix === 'files') return 'clips'
|
||||
if (prefix === 'stills') return 'stills'
|
||||
if (prefix === 'tmp') return 'tmp'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function walkDir(dir: string, breakdown: StorageBreakdown, prefix: string) {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink()) continue
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(full, breakdown, prefix || entry.name)
|
||||
continue
|
||||
}
|
||||
if (!entry.isFile()) continue
|
||||
let size = 0
|
||||
try {
|
||||
size = statSync(full).size
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
breakdown[bucketFor(prefix)] += size
|
||||
}
|
||||
}
|
||||
|
||||
function ownerLibraryBytes(owner: string) {
|
||||
const breakdown = emptyBreakdown()
|
||||
const root = ownerRoot(owner)
|
||||
if (existsSync(root)) walkDir(root, breakdown, '')
|
||||
return {
|
||||
libraryBytes: breakdown.clips + breakdown.stills + breakdown.tmp + breakdown.other,
|
||||
breakdown
|
||||
}
|
||||
}
|
||||
|
||||
function decodeMountField(value: string) {
|
||||
return value.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)))
|
||||
}
|
||||
|
||||
function mountFor(target: string): { mountPoint: string; fstype: string; source: string } | null {
|
||||
if (!existsSync('/proc/self/mountinfo')) return null
|
||||
let text = ''
|
||||
try {
|
||||
text = readFileSync('/proc/self/mountinfo', 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const normalized = target.replace(/\\/g, '/').replace(/\/$/, '') || '/'
|
||||
let best: { mountPoint: string; fstype: string; source: string } | null = null
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
const parts = line.split(' - ')
|
||||
if (parts.length < 2) continue
|
||||
const left = parts[0].split(' ')
|
||||
const right = parts[1].split(' ')
|
||||
const mountPoint = decodeMountField(left[4] || '')
|
||||
const fstype = right[0] || ''
|
||||
const source = decodeMountField(right[1] || '')
|
||||
if (!mountPoint) continue
|
||||
const prefix = mountPoint === '/' ? '/' : `${mountPoint.replace(/\/$/, '')}/`
|
||||
const hit = normalized === mountPoint || normalized.startsWith(prefix) || `${normalized}/` === prefix
|
||||
if (!hit) continue
|
||||
if (!best || mountPoint.length > best.mountPoint.length) {
|
||||
best = { mountPoint, fstype, source }
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function isOverlayFs(fstype: string, source: string) {
|
||||
const type = (fstype || '').toLowerCase()
|
||||
const src = (source || '').toLowerCase()
|
||||
return OVERLAY_TYPES.has(type) || src === 'overlay'
|
||||
}
|
||||
|
||||
async function volumeFromStatfs(path: string): Promise<Omit<VolumeStats, 'overlay'> | null> {
|
||||
try {
|
||||
const stats = await statfs(path)
|
||||
const bsize = Number(stats.bsize)
|
||||
const total = Number(stats.blocks) * bsize
|
||||
const free = Number(stats.bavail) * bsize
|
||||
if (!Number.isFinite(total) || total <= 0) return null
|
||||
return {
|
||||
ok: true,
|
||||
totalBytes: total,
|
||||
usedBytes: Math.max(0, total - (Number.isFinite(free) ? free : 0)),
|
||||
freeBytes: Number.isFinite(free) ? Math.max(0, free) : null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function volumeFromDf(path: string): Promise<Omit<VolumeStats, 'overlay'> | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('df', ['-kP', path], { timeout: 4000, windowsHide: true })
|
||||
const lines = String(stdout || '').trim().split(/\r?\n/).filter(Boolean)
|
||||
const row = lines[lines.length - 1]
|
||||
if (!row || lines.length < 2) return null
|
||||
const cols = row.trim().split(/\s+/)
|
||||
if (cols.length < 6) return null
|
||||
const totalK = Number(cols[cols.length - 5])
|
||||
const usedK = Number(cols[cols.length - 4])
|
||||
const availK = Number(cols[cols.length - 3])
|
||||
if (!Number.isFinite(totalK) || totalK <= 0) return null
|
||||
const total = totalK * 1024
|
||||
const free = Number.isFinite(availK) ? availK * 1024 : null
|
||||
const used = Number.isFinite(usedK) ? usedK * 1024 : (free == null ? null : Math.max(0, total - free))
|
||||
return { ok: true, totalBytes: total, usedBytes: used, freeBytes: free }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function volumeProbePath() {
|
||||
const root = libraryRoot()
|
||||
const candidates = [root, dirname(resolve(root))]
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && existsSync(candidate)) {
|
||||
try {
|
||||
return realpathSync(candidate)
|
||||
} catch {
|
||||
return resolve(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolve(root)
|
||||
}
|
||||
|
||||
async function volumeStats(): Promise<VolumeStats> {
|
||||
const path = volumeProbePath()
|
||||
const mount = mountFor(path.replace(/\\/g, '/'))
|
||||
const overlay = mount ? isOverlayFs(mount.fstype, mount.source) : false
|
||||
const fromStatfs = await volumeFromStatfs(path)
|
||||
if (fromStatfs) return { ...fromStatfs, overlay }
|
||||
const fromDf = await volumeFromDf(path)
|
||||
if (fromDf) return { ...fromDf, overlay }
|
||||
return { ok: false, totalBytes: null, usedBytes: null, freeBytes: null, overlay }
|
||||
}
|
||||
|
||||
export async function libraryStorage(owner: string): Promise<LibraryStorage> {
|
||||
const { libraryBytes, breakdown } = ownerLibraryBytes(owner)
|
||||
return {
|
||||
libraryBytes,
|
||||
breakdown,
|
||||
volume: await volumeStats(),
|
||||
counted: "This account's clips, stills, thumbs, catalogs, queues, and temp files. Shared pending JSON and other accounts are not included."
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { getJob, listJobs, emitJob, type Job } from '~/server/utils/jobs'
|
||||
import { listPendingJobs, patchPendingJob, readPendingJob, deletePendingJob } from '~/server/utils/pending'
|
||||
import { getShotQueue } from '~/server/utils/shotQueue'
|
||||
import { parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
|
||||
import type { PermanenceRef } from '~/utils/globalLocks'
|
||||
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||
|
||||
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
||||
export type StudioJobKind = 'video' | 'edit'
|
||||
@@ -680,6 +680,12 @@ export async function startStudioJob(item: StudioJob) {
|
||||
const { existsSync, readFileSync } = await import('node:fs')
|
||||
|
||||
const payload = item.payload
|
||||
const identity = allowIdentityRefs(
|
||||
payload.useIdentityRefs,
|
||||
payload.permanenceRefs,
|
||||
payload.globalLocks,
|
||||
payload.shotPermanenceRefs
|
||||
)
|
||||
const job = createJob()
|
||||
job.kind = 'video'
|
||||
job.maxStep = payload.steps
|
||||
@@ -715,7 +721,7 @@ export async function startStudioJob(item: StudioJob) {
|
||||
chainLabel: extensions.length ? 'Initial' : undefined,
|
||||
familyId: item.familyId,
|
||||
workflow: parseVideoWorkflow(payload.workflow),
|
||||
useIdentityRefs: payload.useIdentityRefs,
|
||||
useIdentityRefs: identity,
|
||||
queueAutoRun: extensions.length ? payload.queueAutoRun : false,
|
||||
queueBudget: extensions.length && payload.queueAutoRun ? extensions.length : 0,
|
||||
globalLocks: payload.globalLocks,
|
||||
@@ -745,7 +751,7 @@ export async function startStudioJob(item: StudioJob) {
|
||||
scheduler: payload.scheduler,
|
||||
workflow: parseVideoWorkflow(payload.workflow),
|
||||
sound: payload.sound,
|
||||
useIdentityRefs: payload.useIdentityRefs,
|
||||
useIdentityRefs: identity,
|
||||
referenceStillIds: payload.referenceStillIds,
|
||||
globalLocks: payload.globalLocks,
|
||||
permanenceRefs: payload.permanenceRefs,
|
||||
@@ -798,7 +804,7 @@ export async function startStudioJob(item: StudioJob) {
|
||||
extensions,
|
||||
workflow: parseVideoWorkflow(payload.workflow),
|
||||
duration: payload.duration,
|
||||
useIdentityRefs: payload.useIdentityRefs,
|
||||
useIdentityRefs: identity,
|
||||
referenceImages
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
setQueueJob,
|
||||
updateShotQueue
|
||||
} from '~/server/utils/shotQueue'
|
||||
import { composeShotPrompt, type PermanenceRef } from '~/utils/globalLocks'
|
||||
import { composeShotPrompt, allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||
|
||||
export type ChainImage = { filename: string; data: Buffer; type?: string }
|
||||
|
||||
@@ -110,7 +110,12 @@ function paramsFromJob(job: Job): VideoChainParams {
|
||||
extensions: library.extensions || [],
|
||||
workflow,
|
||||
duration,
|
||||
useIdentityRefs: library.useIdentityRefs === true,
|
||||
useIdentityRefs: allowIdentityRefs(
|
||||
library.useIdentityRefs === true,
|
||||
library.permanenceRefs,
|
||||
library.globalLocks,
|
||||
library.shotPermanenceRefs
|
||||
),
|
||||
referenceImages,
|
||||
globalLocks: library.globalLocks,
|
||||
permanenceRefs: library.permanenceRefs,
|
||||
@@ -336,8 +341,11 @@ export async function continueQueuedExtensions(
|
||||
}
|
||||
|
||||
copyFileSync(currentPath, part1Path)
|
||||
const parentId = job.library.parentClipId
|
||||
const parentVideo = parentId ? clipVideoPath(job.library.ownerKey, parentId) : ''
|
||||
const extractSource = parentVideo && existsSync(parentVideo) ? parentVideo : part1Path
|
||||
try {
|
||||
await extractLastFrame(part1Path, framePath)
|
||||
await extractLastFrame(extractSource, framePath)
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(detail.includes('last frame')
|
||||
@@ -345,6 +353,9 @@ export async function continueQueuedExtensions(
|
||||
: `Could not extract the last frame for the next extension: ${detail}`)
|
||||
}
|
||||
const frame = readFileSync(framePath)
|
||||
if (!frame.length || frame.length < 64) {
|
||||
throw new Error('Could not extract the last frame for the next extension: the frame file was empty')
|
||||
}
|
||||
job.library.extendPart1Path = part1Path
|
||||
job.library.prompt = ext.prompt
|
||||
if (live.permanenceRefs?.length) {
|
||||
@@ -354,11 +365,17 @@ export async function continueQueuedExtensions(
|
||||
}
|
||||
const sound = await probeHasAudio(currentPath)
|
||||
const seed = Math.floor(Math.random() * 2_147_483_647)
|
||||
const identity = allowIdentityRefs(
|
||||
params.useIdentityRefs,
|
||||
job.library.permanenceRefs || params.permanenceRefs,
|
||||
job.library.globalLocks || params.globalLocks,
|
||||
job.library.shotPermanenceRefs || params.shotPermanenceRefs
|
||||
)
|
||||
|
||||
await ensureComfyReady(ready)
|
||||
await queueMiniMax(job, {
|
||||
prompt: ext.prompt,
|
||||
image: params.useIdentityRefs
|
||||
image: identity
|
||||
? params.image
|
||||
: { filename: 'last_frame.png', data: frame, type: 'image/png' },
|
||||
width: params.width,
|
||||
@@ -375,8 +392,8 @@ export async function continueQueuedExtensions(
|
||||
persist,
|
||||
workflow: workflowForExtension(params.workflow),
|
||||
duration: ext.duration,
|
||||
useIdentityRefs: params.useIdentityRefs,
|
||||
referenceImages: params.useIdentityRefs ? params.referenceImages : []
|
||||
useIdentityRefs: identity,
|
||||
referenceImages: identity ? params.referenceImages : []
|
||||
})
|
||||
|
||||
shotsLeft -= 1
|
||||
@@ -520,7 +537,12 @@ export async function startQueueBurst(owner: string, queueId: string, count: num
|
||||
familyId: queue.familyId,
|
||||
parentClipId: clipId,
|
||||
workflow: queue.workflow,
|
||||
useIdentityRefs: queue.useIdentityRefs,
|
||||
useIdentityRefs: allowIdentityRefs(
|
||||
queue.useIdentityRefs === true,
|
||||
queue.permanenceRefs,
|
||||
queue.globalLocks,
|
||||
queue.segments.map(segment => segment.permanenceRefs || [])
|
||||
),
|
||||
queueId: queue.id,
|
||||
queueAutoRun: count === 'all',
|
||||
queueBudget: n,
|
||||
|
||||
+11
-1
@@ -5,6 +5,7 @@ import type { PendingJob } from '~/server/utils/pending'
|
||||
import { isLastChainShot, libraryFromPending, pendingFromJob, remainingAfterCurrentShot, writePendingJob, deletePendingJob } from '~/server/utils/pending'
|
||||
import { syncQueueFromJob } from '~/server/utils/shotQueue'
|
||||
import { isEncodingNode, NODE_LABELS } from '~/server/utils/workflow'
|
||||
import { extractFirstFrameFromBuffer } from '~/server/utils/ffmpeg'
|
||||
import { mergePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||
|
||||
function clipRefsFromJob(library: NonNullable<Job['library']>): PermanenceRef[] | undefined {
|
||||
@@ -186,10 +187,18 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
||||
})
|
||||
if (job.library) {
|
||||
let buffer = await downloadComfyVideo(video)
|
||||
let segmentFirstFrame: Buffer | undefined
|
||||
if (stitching) {
|
||||
if (!existsSync(job.library.extendPart1Path!)) {
|
||||
throw new Error('Extension source clip was missing during stitch')
|
||||
}
|
||||
if (job.library.extendTmpDir) {
|
||||
try {
|
||||
segmentFirstFrame = await extractFirstFrameFromBuffer(buffer, job.library.extendTmpDir)
|
||||
} catch {
|
||||
// ensureClipFirstFrame will seek to the join if this frame is missing
|
||||
}
|
||||
}
|
||||
buffer = await stitchExtension({
|
||||
part1Path: job.library.extendPart1Path!,
|
||||
part2: buffer,
|
||||
@@ -225,7 +234,8 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
||||
stillId: job.library.stillId,
|
||||
sound: job.library.sound,
|
||||
globalLocks: job.library.globalLocks,
|
||||
permanenceRefs: clipRefsFromJob(job.library)
|
||||
permanenceRefs: clipRefsFromJob(job.library),
|
||||
segmentFirstFrame
|
||||
})
|
||||
job.clipId = clip.id
|
||||
job.savedPromptId = job.promptId
|
||||
|
||||
+57
-20
@@ -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
|
||||
}
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user