Pause the queue after the current clip for real, and carry global locks plus labeled permanence stills through generate and shot prompts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-zinc-200">
|
||||||
|
Global locks
|
||||||
|
<span class="ml-2 font-normal text-xs text-zinc-500">Every shot inherits these. Each clip adds one beat.</span>
|
||||||
|
</summary>
|
||||||
|
<p class="mt-2 text-xs text-zinc-500">
|
||||||
|
Shared standards for last-frame I2V. Later shots get a compact <span class="text-zinc-300">[INHERITS]</span> line, not this whole block again.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
:value="modelValue"
|
||||||
|
rows="10"
|
||||||
|
class="mt-3 max-h-64 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
/>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-white/15 px-2.5 py-1 text-xs text-zinc-300 hover:border-amber-300/40 hover:text-amber-100"
|
||||||
|
@click="insertTemplate"
|
||||||
|
>
|
||||||
|
Insert template
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="modelValue.trim()"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-white/15 px-2.5 py-1 text-xs text-zinc-400 hover:text-white"
|
||||||
|
@click="emit('update:modelValue', '')"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DEFAULT_GLOBAL_LOCKS } from '~/utils/globalLocks'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const placeholder = DEFAULT_GLOBAL_LOCKS
|
||||||
|
|
||||||
|
function insertTemplate() {
|
||||||
|
emit('update:modelValue', DEFAULT_GLOBAL_LOCKS)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -172,13 +172,15 @@ const props = defineProps<{
|
|||||||
folderId: string
|
folderId: string
|
||||||
hideInputPreview?: boolean
|
hideInputPreview?: boolean
|
||||||
heading?: string
|
heading?: string
|
||||||
|
stillOnly?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
updated: [payload: unknown]
|
updated: [payload: unknown]
|
||||||
error: [message: string]
|
error: [message: string]
|
||||||
select: [file: File, hidden: boolean]
|
select: [file: File, hidden: boolean, stillId?: string]
|
||||||
|
pick: [still: { id: string; filename: string; hideInput?: boolean }]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
@@ -262,8 +264,10 @@ async function ingest(file: File) {
|
|||||||
body.append('folderId', current.value?.id || activeFolderId.value)
|
body.append('folderId', current.value?.id || activeFolderId.value)
|
||||||
body.append('hideInput', String(props.hideInputPreview === true))
|
body.append('hideInput', String(props.hideInputPreview === true))
|
||||||
try {
|
try {
|
||||||
emit('updated', await $fetch('/api/library/stills', { method: 'POST', body }))
|
const result = await $fetch<{ still?: { id: string; filename: string; hideInput?: boolean } }>('/api/library/stills', { method: 'POST', body })
|
||||||
emit('select', file, props.hideInputPreview === true)
|
emit('updated', result)
|
||||||
|
if (result.still?.id) emit('pick', result.still)
|
||||||
|
emit('select', file, props.hideInputPreview === true, result.still?.id)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
emit('error', error?.data?.statusMessage || 'Could not save image')
|
emit('error', error?.data?.statusMessage || 'Could not save image')
|
||||||
}
|
}
|
||||||
@@ -283,10 +287,12 @@ function onDrop(event: DragEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pickStill(still: PickerStill) {
|
async function pickStill(still: PickerStill) {
|
||||||
|
emit('pick', { id: still.id, filename: still.filename, hideInput: still.hideInput })
|
||||||
|
if (props.stillOnly) return
|
||||||
try {
|
try {
|
||||||
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
|
||||||
const file = new File([blob], still.filename, { type: blob.type || 'image/png' })
|
const file = new File([blob], still.filename, { type: blob.type || 'image/png' })
|
||||||
emit('select', file, still.hideInput === true)
|
emit('select', file, still.hideInput === true, still.id)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
emit('error', error?.data?.statusMessage || 'Could not load that still')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="label" class="mb-1.5 flex items-baseline justify-between gap-2">
|
||||||
|
<p class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">{{ label }}</p>
|
||||||
|
<p v-if="hint" class="text-[11px] text-zinc-600">{{ hint }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-start gap-1.5">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in refs"
|
||||||
|
:key="item.stillId + '-' + index"
|
||||||
|
class="flex w-14 flex-col items-center gap-0.5"
|
||||||
|
>
|
||||||
|
<span class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900">
|
||||||
|
<span v-if="hidden" class="sensitive-glow block h-full w-full" />
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="`/api/library/stills/${item.stillId}?thumb=1`"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="!disabled"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-0 top-0 rounded-bl-md bg-zinc-950/80 px-1 text-[10px] leading-4 text-zinc-300 hover:text-red-200"
|
||||||
|
title="Remove"
|
||||||
|
@click.stop="emit('remove', index)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
class="w-full truncate rounded border border-white/10 bg-zinc-950 px-0.5 py-0 text-[10px] text-zinc-400"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="item.label"
|
||||||
|
@change="emit('updateLabel', index, ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option v-for="option in labelOptions(item.label)" :key="option" :value="option">{{ option }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!disabled && refs.length < max"
|
||||||
|
type="button"
|
||||||
|
class="flex h-12 w-12 flex-col items-center justify-center rounded-lg border border-dashed border-white/15 text-[10px] text-zinc-500 hover:border-amber-300/40 hover:text-amber-100"
|
||||||
|
:title="addTitle"
|
||||||
|
@click="emit('add')"
|
||||||
|
>
|
||||||
|
+ Ref
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { PERMANENCE_REF_LABELS, type PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
refs: PermanenceRef[]
|
||||||
|
label?: string
|
||||||
|
hint?: string
|
||||||
|
hidden?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
max?: number
|
||||||
|
addTitle?: string
|
||||||
|
}>(), {
|
||||||
|
max: 6,
|
||||||
|
addTitle: 'Attach a permanence still'
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
add: []
|
||||||
|
remove: [index: number]
|
||||||
|
updateLabel: [index: number, label: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function labelOptions(current: string) {
|
||||||
|
if (PERMANENCE_REF_LABELS.includes(current as typeof PERMANENCE_REF_LABELS[number])) return [...PERMANENCE_REF_LABELS]
|
||||||
|
return [...PERMANENCE_REF_LABELS, current]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+220
-8
@@ -167,7 +167,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="shotScriptMode && studioMode === 'video'" class="mb-2 text-xs text-zinc-500">
|
<p v-if="shotScriptMode && studioMode === 'video'" class="mb-2 text-xs text-zinc-500">
|
||||||
Paste a block. Text before the first marker is shot 1. Put <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span> on their own lines to queue extensions — a line like <span class="font-medium text-zinc-300">Shot Type</span> will not start a new shot. Every shot uses the duration slider.
|
Paste a block. Text before the first marker is shot 1. Put <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span> on their own lines to queue extensions — a line like <span class="font-medium text-zinc-300">Shot Type</span> will not start a new shot. Every shot uses the duration slider. Global locks are inherited; each shot should add one beat.
|
||||||
<span v-if="identityPrompting"> The Comfy identity prefix is prepended to every shot, and the same Picture 1 / extra stills are sent with each shot.</span>
|
<span v-if="identityPrompting"> The Comfy identity prefix is prepended to every shot, and the same Picture 1 / extra stills are sent with each shot.</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
|
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
|
||||||
@@ -274,6 +274,18 @@
|
|||||||
<p class="mt-3 text-[10px] uppercase tracking-wide text-zinc-500">Concatenated result</p>
|
<p class="mt-3 text-[10px] uppercase tracking-wide text-zinc-500">Concatenated result</p>
|
||||||
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedIdentityPrompt }}</p>
|
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedIdentityPrompt }}</p>
|
||||||
</details>
|
</details>
|
||||||
|
<div v-if="studioMode === 'video'" class="mt-3 space-y-3">
|
||||||
|
<GlobalLocksEditor v-model="globalLocks" />
|
||||||
|
<PermanenceRefStrip
|
||||||
|
:refs="familyPermanenceRefs"
|
||||||
|
label="Permanence refs"
|
||||||
|
hint="Labels for object permanence. Last-frame I2V inherits them in the prompt; they are not extra Comfy identity pictures."
|
||||||
|
:hidden="hideInputPreview"
|
||||||
|
@add="openPermanencePicker()"
|
||||||
|
@remove="removeFamilyRef"
|
||||||
|
@update-label="updateFamilyRefLabel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 hidden space-y-2 md:block">
|
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 hidden space-y-2 md:block">
|
||||||
<p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
|
<p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
|
||||||
<div
|
<div
|
||||||
@@ -285,7 +297,17 @@
|
|||||||
{{ index === 0 ? 'Initial' : `Extension ${index}` }}
|
{{ index === 0 ? 'Initial' : `Extension ${index}` }}
|
||||||
<span class="font-normal text-zinc-500"> · shot {{ shot.n }} · {{ formatClipDuration(duration) }}</span>
|
<span class="font-normal text-zinc-500"> · shot {{ shot.n }} · {{ formatClipDuration(duration) }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ identityPrompting ? buildIdentityPrompt(shot.prompt, extraIdentityPictures) : shot.prompt }}</p>
|
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedShotPreview(shot.prompt, index, shot.n) }}</p>
|
||||||
|
<PermanenceRefStrip
|
||||||
|
class="mt-2"
|
||||||
|
:refs="shotPermanenceRefs[shot.n] || []"
|
||||||
|
hint="This shot"
|
||||||
|
:hidden="hideInputPreview"
|
||||||
|
:max="4"
|
||||||
|
@add="openPermanencePicker(shot.n)"
|
||||||
|
@remove="(refIndex) => removeShotRef(shot.n, refIndex)"
|
||||||
|
@update-label="(refIndex, label) => updateShotRefLabel(shot.n, refIndex, label)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,6 +336,16 @@
|
|||||||
placeholder="Motion prompt for this extension…"
|
placeholder="Motion prompt for this extension…"
|
||||||
/>
|
/>
|
||||||
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
|
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
|
||||||
|
<PermanenceRefStrip
|
||||||
|
class="mt-3"
|
||||||
|
:refs="shotPermanenceRefs[index + 2] || []"
|
||||||
|
hint="This extension"
|
||||||
|
:hidden="hideInputPreview"
|
||||||
|
:max="4"
|
||||||
|
@add="openPermanencePicker(index + 2)"
|
||||||
|
@remove="(refIndex) => removeShotRef(index + 2, refIndex)"
|
||||||
|
@update-label="(refIndex, label) => updateShotRefLabel(index + 2, refIndex, label)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1116,6 +1148,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-zinc-500">{{ clipMetaLine(entry.group.head) }}</p>
|
<p class="text-xs text-zinc-500">{{ clipMetaLine(entry.group.head) }}</p>
|
||||||
|
<p v-if="entry.group.head.globalLocks" class="mt-1 text-[11px] text-zinc-600">Global locks saved with this collection.</p>
|
||||||
|
<PermanenceRefStrip
|
||||||
|
v-if="entry.group.parts.length === 1 && !entry.group.head.hideThumbnail"
|
||||||
|
class="mt-2"
|
||||||
|
:refs="entry.group.head.permanenceRefs || []"
|
||||||
|
:hidden="entry.group.head.hideInput || hideInputPreview"
|
||||||
|
@add="openPermanencePicker(undefined, entry.group.head.id)"
|
||||||
|
@remove="(refIndex) => removeClipRef(entry.group.head, refIndex)"
|
||||||
|
@update-label="(refIndex, label) => updateClipRefLabel(entry.group.head, refIndex, label)"
|
||||||
|
/>
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||||
<button v-if="entry.group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.group.key)">
|
<button v-if="entry.group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.group.key)">
|
||||||
{{ expandedFamilies[entry.group.key] ? 'Collapse' : `Expand ${entry.group.parts.length} parts` }}
|
{{ expandedFamilies[entry.group.key] ? 'Collapse' : `Expand ${entry.group.parts.length} parts` }}
|
||||||
@@ -1170,6 +1212,15 @@
|
|||||||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
|
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-zinc-500">{{ clipMetaLine(item) }}</p>
|
<p class="text-xs text-zinc-500">{{ clipMetaLine(item) }}</p>
|
||||||
|
<PermanenceRefStrip
|
||||||
|
v-if="!item.hideThumbnail"
|
||||||
|
class="mt-2"
|
||||||
|
:refs="item.permanenceRefs || []"
|
||||||
|
:hidden="item.hideInput || hideInputPreview"
|
||||||
|
@add="openPermanencePicker(undefined, item.id)"
|
||||||
|
@remove="(refIndex) => removeClipRef(item, refIndex)"
|
||||||
|
@update-label="(refIndex, label) => updateClipRefLabel(item, refIndex, label)"
|
||||||
|
/>
|
||||||
<div class="mt-1.5 flex flex-wrap items-center gap-3">
|
<div class="mt-1.5 flex flex-wrap items-center gap-3">
|
||||||
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button>
|
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button>
|
||||||
<button type="button" class="text-xs text-amber-300" @click="openExtend(item)">Extend</button>
|
<button type="button" class="text-xs text-amber-300" @click="openExtend(item)">Extend</button>
|
||||||
@@ -1360,10 +1411,12 @@
|
|||||||
:folder-id="folderId"
|
:folder-id="folderId"
|
||||||
:hide-input-preview="hideInputPreview"
|
:hide-input-preview="hideInputPreview"
|
||||||
:heading="pickerHeading"
|
:heading="pickerHeading"
|
||||||
|
:still-only="permanencePicker"
|
||||||
@close="pickerOpen = false"
|
@close="pickerOpen = false"
|
||||||
@updated="applyLibrary"
|
@updated="applyLibrary"
|
||||||
@error="toast"
|
@error="toast"
|
||||||
@select="onPickedImage"
|
@select="onPickedImage"
|
||||||
|
@pick="onPickedPermanence"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
|
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
@@ -1381,6 +1434,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
|
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
|
||||||
import { parseShotScript } from '~/utils/parseShots'
|
import { parseShotScript } from '~/utils/parseShots'
|
||||||
|
import {
|
||||||
|
composeShotPrompt,
|
||||||
|
extractGlobalLocks,
|
||||||
|
isBlankGlobalLocks,
|
||||||
|
nextPermanenceLabel,
|
||||||
|
type PermanenceRef
|
||||||
|
} from '~/utils/globalLocks'
|
||||||
import {
|
import {
|
||||||
composeVideoWorkflow,
|
composeVideoWorkflow,
|
||||||
isLtxWorkflow,
|
isLtxWorkflow,
|
||||||
@@ -1452,6 +1512,8 @@ interface LibraryClip {
|
|||||||
stillId?: string
|
stillId?: string
|
||||||
sound?: boolean
|
sound?: boolean
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: PermanenceRef[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RetryDraft {
|
interface RetryDraft {
|
||||||
@@ -1476,8 +1538,11 @@ interface RetryDraft {
|
|||||||
fps?: number
|
fps?: number
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
extensions?: { prompt: string; duration: number }[]
|
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
|
||||||
workflow?: string
|
workflow?: string
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: PermanenceRef[]
|
||||||
|
shotPermanenceRefs?: PermanenceRef[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueuedExtension {
|
interface QueuedExtension {
|
||||||
@@ -1669,7 +1734,7 @@ const studioJobs = ref<Array<{
|
|||||||
}>>([])
|
}>>([])
|
||||||
const outputStudioJobId = ref('')
|
const outputStudioJobId = ref('')
|
||||||
const pickerOpen = ref(false)
|
const pickerOpen = ref(false)
|
||||||
const pickerSlot = ref<'main' | 'editRef' | number>('main')
|
const pickerSlot = ref<'main' | 'editRef' | number | 'perm' | `perm:${number}` | `clip:${string}`>('main')
|
||||||
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
|
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
|
||||||
const selectedKeys = ref<string[]>([])
|
const selectedKeys = ref<string[]>([])
|
||||||
const lastSelectedKey = ref('')
|
const lastSelectedKey = ref('')
|
||||||
@@ -1682,11 +1747,18 @@ const ltxVideo = computed(() => isLtxWorkflow(videoWorkflow.value))
|
|||||||
const useIdentityRefs = ref(false)
|
const useIdentityRefs = ref(false)
|
||||||
const identityRefs = ref<(File | null)[]>([null, null, null, null])
|
const identityRefs = ref<(File | null)[]>([null, null, null, null])
|
||||||
const identityPreviews = ref(['', '', '', ''])
|
const identityPreviews = ref(['', '', '', ''])
|
||||||
|
const globalLocks = ref('')
|
||||||
|
const familyPermanenceRefs = ref<PermanenceRef[]>([])
|
||||||
|
const shotPermanenceRefs = ref<Record<number, PermanenceRef[]>>({})
|
||||||
|
const LOCKS_STORE = 'aigen-global-locks'
|
||||||
|
const REFS_STORE = 'aigen-permanence-refs'
|
||||||
const pickerHeading = computed(() => {
|
const pickerHeading = computed(() => {
|
||||||
if (pickerSlot.value === 'main') return studioMode.value === 'edit' ? 'Choose image 1' : 'Choose an image'
|
if (pickerSlot.value === 'main') return studioMode.value === 'edit' ? 'Choose image 1' : 'Choose an image'
|
||||||
if (pickerSlot.value === 'editRef') return 'Choose image 2'
|
if (pickerSlot.value === 'editRef') return 'Choose image 2'
|
||||||
|
if (typeof pickerSlot.value === 'string') return 'Choose a permanence still'
|
||||||
return `Choose Picture ${Number(pickerSlot.value) + 2}`
|
return `Choose Picture ${Number(pickerSlot.value) + 2}`
|
||||||
})
|
})
|
||||||
|
const permanencePicker = computed(() => typeof pickerSlot.value === 'string' && pickerSlot.value !== 'main' && pickerSlot.value !== 'editRef')
|
||||||
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
|
||||||
let videoSource: EventSource | null = null
|
let videoSource: EventSource | null = null
|
||||||
@@ -1902,6 +1974,16 @@ const recommendedShots = computed(() => {
|
|||||||
})
|
})
|
||||||
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value)
|
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.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) {
|
||||||
|
const composed = composeShotPrompt({
|
||||||
|
globalLocks: globalLocks.value,
|
||||||
|
prompt: text,
|
||||||
|
shotIndex: index,
|
||||||
|
familyRefs: familyPermanenceRefs.value,
|
||||||
|
shotRefs: shotPermanenceRefs.value[shotN ?? index + 1]
|
||||||
|
})
|
||||||
|
return identityPrompting.value ? buildIdentityPrompt(composed, extraIdentityPictures.value) : composed
|
||||||
|
}
|
||||||
const identityActionPrompt = computed(() => {
|
const identityActionPrompt = computed(() => {
|
||||||
if (shotScriptMode.value) return parsedShots.value[0]?.prompt || prompt.value
|
if (shotScriptMode.value) return parsedShots.value[0]?.prompt || prompt.value
|
||||||
return prompt.value
|
return prompt.value
|
||||||
@@ -2315,6 +2397,12 @@ onMounted(async () => {
|
|||||||
if (videoEngine.value === 'ltx') applyEngineDefaults('ltx')
|
if (videoEngine.value === 'ltx') applyEngineDefaults('ltx')
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
|
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
|
||||||
|
globalLocks.value = localStorage.getItem(LOCKS_STORE) || ''
|
||||||
|
try {
|
||||||
|
const savedRefs = JSON.parse(localStorage.getItem(REFS_STORE) || '')
|
||||||
|
familyPermanenceRefs.value = Array.isArray(savedRefs?.family) ? savedRefs.family : []
|
||||||
|
shotPermanenceRefs.value = savedRefs?.shots && typeof savedRefs.shots === 'object' ? savedRefs.shots : {}
|
||||||
|
} catch { /* ignore */ }
|
||||||
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
|
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
|
||||||
const savedPreset = localStorage.getItem('aigen-prompt-preset')
|
const savedPreset = localStorage.getItem('aigen-prompt-preset')
|
||||||
if (savedPreset) recommendPresetId.value = savedPreset
|
if (savedPreset) recommendPresetId.value = savedPreset
|
||||||
@@ -2426,6 +2514,13 @@ watch(useIdentityRefs, (value) => {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch([globalLocks, familyPermanenceRefs, shotPermanenceRefs], () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCKS_STORE, globalLocks.value)
|
||||||
|
localStorage.setItem(REFS_STORE, JSON.stringify({ family: familyPermanenceRefs.value, shots: shotPermanenceRefs.value }))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
watch(turbo, (on) => {
|
watch(turbo, (on) => {
|
||||||
if (cfgTouched.value) return
|
if (cfgTouched.value) return
|
||||||
cfg.value = ltxVideo.value ? 3.5 : (on ? CFG_TURBO : CFG_QUALITY)
|
cfg.value = ltxVideo.value ? 3.5 : (on ? CFG_TURBO : CFG_QUALITY)
|
||||||
@@ -2569,8 +2664,10 @@ function applyRecommend() {
|
|||||||
prompt.value = text
|
prompt.value = text
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
prompt.value = text
|
const extracted = extractGlobalLocks(text)
|
||||||
if (looksLikeShotScript(text) || parseShotScript(text).length > 1) shotScriptMode.value = true
|
if (extracted.locks && isBlankGlobalLocks(globalLocks.value)) globalLocks.value = extracted.locks
|
||||||
|
prompt.value = extracted.script || text
|
||||||
|
if (looksLikeShotScript(prompt.value) || parseShotScript(prompt.value).length > 1) shotScriptMode.value = true
|
||||||
}
|
}
|
||||||
recommendApplied.value = true
|
recommendApplied.value = true
|
||||||
toast('Recommended prompt applied')
|
toast('Recommended prompt applied')
|
||||||
@@ -2625,6 +2722,9 @@ function clearVideoForm() {
|
|||||||
clipName.value = ''
|
clipName.value = ''
|
||||||
extensionQueue.value = []
|
extensionQueue.value = []
|
||||||
shotScriptMode.value = false
|
shotScriptMode.value = false
|
||||||
|
globalLocks.value = ''
|
||||||
|
familyPermanenceRefs.value = []
|
||||||
|
shotPermanenceRefs.value = {}
|
||||||
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
||||||
recommendText.value = ''
|
recommendText.value = ''
|
||||||
recommendError.value = ''
|
recommendError.value = ''
|
||||||
@@ -2649,6 +2749,81 @@ function openPicker(slot: 'main' | 'editRef' | number = 'main') {
|
|||||||
pickerOpen.value = true
|
pickerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPermanencePicker(shot?: number, clipId?: string) {
|
||||||
|
if (clipId) pickerSlot.value = `clip:${clipId}`
|
||||||
|
else if (typeof shot === 'number') pickerSlot.value = `perm:${shot}`
|
||||||
|
else pickerSlot.value = 'perm'
|
||||||
|
pickerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPermanenceRef(stillId: string, shot?: number) {
|
||||||
|
if (typeof shot === 'number') {
|
||||||
|
const current = shotPermanenceRefs.value[shot] || []
|
||||||
|
if (current.some(item => item.stillId === stillId) || current.length >= 4) return
|
||||||
|
shotPermanenceRefs.value = { ...shotPermanenceRefs.value, [shot]: [...current, { stillId, label: nextPermanenceLabel(current) }] }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (familyPermanenceRefs.value.some(item => item.stillId === stillId) || familyPermanenceRefs.value.length >= 6) return
|
||||||
|
familyPermanenceRefs.value = [...familyPermanenceRefs.value, { stillId, label: nextPermanenceLabel(familyPermanenceRefs.value) }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFamilyRef(index: number) {
|
||||||
|
familyPermanenceRefs.value = familyPermanenceRefs.value.filter((_, i) => i !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFamilyRefLabel(index: number, label: string) {
|
||||||
|
familyPermanenceRefs.value = familyPermanenceRefs.value.map((item, i) => i === index ? { ...item, label } : item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeShotRef(shot: number, index: number) {
|
||||||
|
const current = (shotPermanenceRefs.value[shot] || []).filter((_, i) => i !== index)
|
||||||
|
const next = { ...shotPermanenceRefs.value }
|
||||||
|
if (current.length) next[shot] = current
|
||||||
|
else delete next[shot]
|
||||||
|
shotPermanenceRefs.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateShotRefLabel(shot: number, index: number, label: string) {
|
||||||
|
const current = (shotPermanenceRefs.value[shot] || []).map((item, i) => i === index ? { ...item, label } : item)
|
||||||
|
shotPermanenceRefs.value = { ...shotPermanenceRefs.value, [shot]: current }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistClipRefs(clip: LibraryClip, refs: PermanenceRef[]) {
|
||||||
|
try {
|
||||||
|
applyLibrary(await $fetch(`/api/library/clips/${clip.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { permanenceRefs: refs }
|
||||||
|
}))
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(error?.data?.statusMessage || 'Could not save permanence refs')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeClipRef(clip: LibraryClip, index: number) {
|
||||||
|
void persistClipRefs(clip, (clip.permanenceRefs || []).filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClipRefLabel(clip: LibraryClip, index: number, label: string) {
|
||||||
|
void persistClipRefs(clip, (clip.permanenceRefs || []).map((item, i) => i === index ? { ...item, label } : item))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickedPermanence(still: { id: string }) {
|
||||||
|
const slot = pickerSlot.value
|
||||||
|
pickerOpen.value = false
|
||||||
|
if (typeof slot === 'string' && slot.startsWith('clip:')) {
|
||||||
|
const clipId = slot.slice(5)
|
||||||
|
const clip = clips.value.find(item => item.id === clipId)
|
||||||
|
if (!clip) return
|
||||||
|
if ((clip.permanenceRefs || []).some(item => item.stillId === still.id)) return
|
||||||
|
void persistClipRefs(clip, [...(clip.permanenceRefs || []), { stillId: still.id, label: nextPermanenceLabel(clip.permanenceRefs || []) }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof slot === 'string' && (slot === 'perm' || slot.startsWith('perm:'))) {
|
||||||
|
const shot = slot === 'perm' ? undefined : Number(slot.slice(5))
|
||||||
|
addPermanenceRef(still.id, Number.isFinite(shot) ? shot : undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setEditRef(next: File) {
|
function setEditRef(next: File) {
|
||||||
if (editRefPreview.value) URL.revokeObjectURL(editRefPreview.value)
|
if (editRefPreview.value) URL.revokeObjectURL(editRefPreview.value)
|
||||||
editRefFile.value = next
|
editRefFile.value = next
|
||||||
@@ -2686,6 +2861,7 @@ function clearIdentityRef(index: number) {
|
|||||||
function onPickedImage(next: File, hidden = false) {
|
function onPickedImage(next: File, hidden = false) {
|
||||||
const slot = pickerSlot.value
|
const slot = pickerSlot.value
|
||||||
pickerOpen.value = false
|
pickerOpen.value = false
|
||||||
|
if (typeof slot === 'string' && slot !== 'main' && slot !== 'editRef') return
|
||||||
if (slot === 'editRef') {
|
if (slot === 'editRef') {
|
||||||
setEditRef(next)
|
setEditRef(next)
|
||||||
return
|
return
|
||||||
@@ -2864,8 +3040,11 @@ async function restoreStudioJob(id: string) {
|
|||||||
hideThumbnail?: boolean
|
hideThumbnail?: boolean
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
referenceStillIds?: Array<string | null>
|
referenceStillIds?: Array<string | null>
|
||||||
extensions?: { prompt: string; duration: number }[]
|
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
|
||||||
queueAutoRun?: boolean
|
queueAutoRun?: boolean
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: PermanenceRef[]
|
||||||
|
shotPermanenceRefs?: PermanenceRef[][]
|
||||||
}
|
}
|
||||||
}>(`/api/studio-queue/${id}`)
|
}>(`/api/studio-queue/${id}`)
|
||||||
const payload = job.payload
|
const payload = job.payload
|
||||||
@@ -2907,6 +3086,13 @@ async function restoreStudioJob(id: string) {
|
|||||||
duration: clampDuration(Number(item.duration))
|
duration: clampDuration(Number(item.duration))
|
||||||
}))
|
}))
|
||||||
queueAutoRun.value = payload.queueAutoRun === true
|
queueAutoRun.value = payload.queueAutoRun === true
|
||||||
|
globalLocks.value = payload.globalLocks || ''
|
||||||
|
familyPermanenceRefs.value = payload.permanenceRefs || []
|
||||||
|
const shotRefs: Record<number, PermanenceRef[]> = {}
|
||||||
|
;(payload.shotPermanenceRefs || []).forEach((refs, index) => {
|
||||||
|
if (refs?.length) shotRefs[index + 1] = refs
|
||||||
|
})
|
||||||
|
shotPermanenceRefs.value = shotRefs
|
||||||
const stillId = payload.stillId
|
const stillId = payload.stillId
|
||||||
if (stillId) {
|
if (stillId) {
|
||||||
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
|
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
|
||||||
@@ -3131,6 +3317,16 @@ async function rerun(item: LibraryClip, collection = false) {
|
|||||||
shotScriptMode.value = restoreAll
|
shotScriptMode.value = restoreAll
|
||||||
useIdentityRefs.value = false
|
useIdentityRefs.value = false
|
||||||
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
||||||
|
const ordered = chronologicalParts(parts)
|
||||||
|
globalLocks.value = ordered.find(part => part.globalLocks)?.globalLocks || target.globalLocks || ''
|
||||||
|
familyPermanenceRefs.value = ordered[0]?.permanenceRefs || target.permanenceRefs || []
|
||||||
|
const familyIds = new Set(familyPermanenceRefs.value.map(ref => ref.stillId))
|
||||||
|
const nextShots: Record<number, PermanenceRef[]> = {}
|
||||||
|
ordered.forEach((part, index) => {
|
||||||
|
const extras = (part.permanenceRefs || []).filter(ref => !familyIds.has(ref.stillId))
|
||||||
|
if (extras.length) nextShots[index + 1] = extras
|
||||||
|
})
|
||||||
|
shotPermanenceRefs.value = nextShots
|
||||||
extensionQueue.value = restoreAll
|
extensionQueue.value = restoreAll
|
||||||
? parts.slice(1).map(part => ({
|
? parts.slice(1).map(part => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -3169,6 +3365,13 @@ async function loadDraft(draft: RetryDraft) {
|
|||||||
if (draft.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
if (draft.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
||||||
duration.value = clampDuration(Number(draft.duration))
|
duration.value = clampDuration(Number(draft.duration))
|
||||||
applyVideoWorkflow(draft.workflow)
|
applyVideoWorkflow(draft.workflow)
|
||||||
|
globalLocks.value = draft.globalLocks || ''
|
||||||
|
familyPermanenceRefs.value = draft.permanenceRefs || []
|
||||||
|
const draftShots: Record<number, PermanenceRef[]> = {}
|
||||||
|
;(draft.shotPermanenceRefs || []).forEach((refs, index) => {
|
||||||
|
if (refs?.length) draftShots[index + 1] = refs
|
||||||
|
})
|
||||||
|
shotPermanenceRefs.value = draftShots
|
||||||
extensionQueue.value = (draft.extensions || []).map(item => ({
|
extensionQueue.value = (draft.extensions || []).map(item => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
prompt: String(item.prompt || ''),
|
prompt: String(item.prompt || ''),
|
||||||
@@ -3857,9 +4060,18 @@ async function generate() {
|
|||||||
if (ref) body.append(`refImage${index}`, ref)
|
if (ref) body.append(`refImage${index}`, ref)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (globalLocks.value.trim()) body.append('globalLocks', globalLocks.value)
|
||||||
|
body.append('permanenceRefs', JSON.stringify(familyPermanenceRefs.value))
|
||||||
|
const shotRefList = shots
|
||||||
|
? shots.map(shot => shotPermanenceRefs.value[shot.n] || [])
|
||||||
|
: [shotPermanenceRefs.value[1] || [], ...queued.map((_, index) => shotPermanenceRefs.value[index + 2] || [])]
|
||||||
|
body.append('shotPermanenceRefs', JSON.stringify(shotRefList))
|
||||||
if (activeDraftId) body.append('draftId', activeDraftId)
|
if (activeDraftId) body.append('draftId', activeDraftId)
|
||||||
if (queued.length) {
|
if (queued.length) {
|
||||||
body.append('extensions', JSON.stringify(queued))
|
body.append('extensions', JSON.stringify(queued.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
permanenceRefs: shotRefList[index + 1] || []
|
||||||
|
}))))
|
||||||
body.append('queueAutoRun', String(queueAutoRun.value))
|
body.append('queueAutoRun', String(queueAutoRun.value))
|
||||||
}
|
}
|
||||||
const downloadName = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
|
const downloadName = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
|
||||||
|
|||||||
+139
-16
@@ -26,12 +26,23 @@
|
|||||||
Jobs you submitted from Studio. A long shot script is one job; its shots are the list underneath. Run next inserts a waiting job after the current shot as its own clip.
|
Jobs you submitted from Studio. A long shot script is one job; its shots are the list underneath. Run next inserts a waiting job after the current shot as its own clip.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
<p v-if="waitingCount" class="text-sm text-amber-100">{{ waitingCount }} job{{ waitingCount === 1 ? '' : 's' }} waiting</p>
|
<p v-if="waitingCount" class="text-sm text-amber-100">{{ waitingCount }} job{{ waitingCount === 1 ? '' : 's' }} waiting</p>
|
||||||
<p v-else-if="pendingCount" class="text-sm text-amber-100">{{ pendingCount }} shot{{ pendingCount === 1 ? '' : 's' }} waiting</p>
|
<p v-else-if="pendingCount" class="text-sm text-amber-100">{{ pendingCount }} shot{{ pendingCount === 1 ? '' : 's' }} waiting</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200 hover:border-red-300 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:class="pauseToggleClass(pauseArmed)"
|
||||||
|
:disabled="pauseBusy || (!anythingGenerating && !pauseArmed)"
|
||||||
|
:aria-pressed="pauseArmed"
|
||||||
|
:title="pauseDisabledReason || pauseToggleTitle"
|
||||||
|
@click="togglePause()"
|
||||||
|
>
|
||||||
|
{{ pauseToggleLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="min-h-10 rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200 hover:border-red-300 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
:disabled="!clearableCount || Boolean(busyId)"
|
:disabled="!clearableCount || Boolean(busyId)"
|
||||||
@click="clearAll"
|
@click="clearAll"
|
||||||
>
|
>
|
||||||
@@ -41,6 +52,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
||||||
|
<p v-else-if="pauseArmed && anythingGenerating" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
|
||||||
|
Will pause after this video. The current clip keeps generating; the next queued job will not start until you resume.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="pauseArmed" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
|
||||||
|
Paused after current. Waiting jobs will not start until you press Resume queue.
|
||||||
|
</p>
|
||||||
<p v-else-if="!jobs.length && !orphans.length" class="glass rounded-3xl px-5 py-10 text-center text-sm text-zinc-400">
|
<p v-else-if="!jobs.length && !orphans.length" class="glass rounded-3xl px-5 py-10 text-center text-sm text-zinc-400">
|
||||||
Nothing queued. Generate from Studio — a 30-shot script takes one slot, and you can line up more jobs behind it.
|
Nothing queued. Generate from Studio — a 30-shot script takes one slot, and you can line up more jobs behind it.
|
||||||
</p>
|
</p>
|
||||||
@@ -94,14 +111,30 @@
|
|||||||
Run remaining
|
Run remaining
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="job.shots.status === 'running'"
|
v-if="canPauseJob(job)"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200"
|
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
@click="pause(job.shots)"
|
:class="pauseToggleClass(jobIsArmed(job))"
|
||||||
|
:disabled="pauseBusy"
|
||||||
|
:aria-pressed="jobIsArmed(job)"
|
||||||
|
:title="jobIsArmed(job) ? 'Click to keep going after this video' : 'Finish the current video, then stop'"
|
||||||
|
@click="togglePause({ job })"
|
||||||
>
|
>
|
||||||
Pause after this shot
|
{{ jobIsArmed(job) ? 'Will pause after this' : 'Pause after this' }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
<button
|
||||||
|
v-else-if="canPauseJob(job)"
|
||||||
|
type="button"
|
||||||
|
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:class="pauseToggleClass(jobIsArmed(job))"
|
||||||
|
:disabled="pauseBusy"
|
||||||
|
:aria-pressed="jobIsArmed(job)"
|
||||||
|
:title="jobIsArmed(job) ? 'Click to keep going after this video' : 'Finish the current video, then stop'"
|
||||||
|
@click="togglePause({ job })"
|
||||||
|
>
|
||||||
|
{{ jobIsArmed(job) ? 'Will pause after this' : 'Pause after this' }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="job.status === 'waiting' || job.status === 'held'"
|
v-if="job.status === 'waiting' || job.status === 'held'"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -217,12 +250,16 @@
|
|||||||
Run remaining
|
Run remaining
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="queue.status === 'running'"
|
v-if="queue.status === 'running' || queue.stopAfterCurrent"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200"
|
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
@click="pause(queue)"
|
:class="pauseToggleClass(Boolean(queue.stopAfterCurrent) || queuePaused)"
|
||||||
|
:disabled="pauseBusy || (queue.status !== 'running' && !queue.stopAfterCurrent && !queuePaused)"
|
||||||
|
:aria-pressed="Boolean(queue.stopAfterCurrent) || queuePaused"
|
||||||
|
:title="(queue.stopAfterCurrent || queuePaused) ? 'Click to keep going after this video' : 'Finish the current video, then stop'"
|
||||||
|
@click="togglePause({ queue })"
|
||||||
>
|
>
|
||||||
Pause after this shot
|
{{ queue.stopAfterCurrent || queuePaused ? (queue.status === 'running' ? 'Will pause after this' : 'Paused after current') : 'Pause after this' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="queue.status !== 'running'"
|
v-if="queue.status !== 'running'"
|
||||||
@@ -312,6 +349,7 @@ type Queue = {
|
|||||||
completedCount: number
|
completedCount: number
|
||||||
totalCount: number
|
totalCount: number
|
||||||
currentJobId?: string
|
currentJobId?: string
|
||||||
|
stopAfterCurrent?: boolean
|
||||||
segments: Segment[]
|
segments: Segment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +362,8 @@ type StudioJobRow = {
|
|||||||
shotQueueId?: string
|
shotQueueId?: string
|
||||||
cutIn?: boolean
|
cutIn?: boolean
|
||||||
holdForCutIn?: boolean
|
holdForCutIn?: boolean
|
||||||
|
pauseAfterCurrent?: boolean
|
||||||
|
pausedByUser?: boolean
|
||||||
lastError?: string
|
lastError?: string
|
||||||
shots?: Queue | null
|
shots?: Queue | null
|
||||||
plannedShots?: { prompt: string; duration: number }[]
|
plannedShots?: { prompt: string; duration: number }[]
|
||||||
@@ -336,6 +376,7 @@ const jobs = ref<StudioJobRow[]>([])
|
|||||||
const orphans = ref<Queue[]>([])
|
const orphans = ref<Queue[]>([])
|
||||||
const waitingCount = ref(0)
|
const waitingCount = ref(0)
|
||||||
const pendingCount = ref(0)
|
const pendingCount = ref(0)
|
||||||
|
const queuePaused = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const busyId = ref('')
|
const busyId = ref('')
|
||||||
const liveMessage = ref<Record<string, string>>({})
|
const liveMessage = ref<Record<string, string>>({})
|
||||||
@@ -344,6 +385,31 @@ const sources = new Map<string, EventSource>()
|
|||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const canCutIn = computed(() => jobs.value.some(job => job.status === 'running' || job.status === 'held'))
|
const canCutIn = computed(() => jobs.value.some(job => job.status === 'running' || job.status === 'held'))
|
||||||
|
const pauseBusy = computed(() => busyId.value === 'pause')
|
||||||
|
const anythingGenerating = computed(() => (
|
||||||
|
jobs.value.some(job => job.status === 'running' || job.shots?.status === 'running')
|
||||||
|
|| orphans.value.some(queue => queue.status === 'running')
|
||||||
|
))
|
||||||
|
const pauseArmed = computed(() => (
|
||||||
|
queuePaused.value
|
||||||
|
|| jobs.value.some(job => job.pauseAfterCurrent || job.pausedByUser || job.shots?.stopAfterCurrent)
|
||||||
|
|| orphans.value.some(queue => queue.stopAfterCurrent)
|
||||||
|
))
|
||||||
|
const pauseToggleLabel = computed(() => {
|
||||||
|
if (pauseArmed.value && anythingGenerating.value) return 'Will pause after this'
|
||||||
|
if (pauseArmed.value) return 'Resume queue'
|
||||||
|
return 'Pause after current'
|
||||||
|
})
|
||||||
|
const pauseToggleTitle = computed(() => {
|
||||||
|
if (pauseArmed.value && anythingGenerating.value) return 'Click to keep going after this video'
|
||||||
|
if (pauseArmed.value) return 'Start the next waiting job'
|
||||||
|
return 'Finish the current video, then stop. Does not cancel the one that’s generating.'
|
||||||
|
})
|
||||||
|
const pauseDisabledReason = computed(() => {
|
||||||
|
if (pauseBusy.value) return 'Updating pause…'
|
||||||
|
if (!anythingGenerating.value && !pauseArmed.value) return 'Nothing is generating, so there is nothing to pause after'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
const clearableCount = computed(() => (
|
const clearableCount = computed(() => (
|
||||||
jobs.value.filter(job => job.status === 'waiting' || job.status === 'held').length
|
jobs.value.filter(job => job.status === 'waiting' || job.status === 'held').length
|
||||||
+ orphans.value.filter(queue => queue.status !== 'running').length
|
+ orphans.value.filter(queue => queue.status !== 'running').length
|
||||||
@@ -366,7 +432,8 @@ function progressLine(queue: Queue) {
|
|||||||
const next = nextSegment(queue)
|
const next = nextSegment(queue)
|
||||||
if (queue.status === 'complete') return `All ${queue.totalCount} shots saved.`
|
if (queue.status === 'complete') return `All ${queue.totalCount} shots saved.`
|
||||||
if (queue.status === 'running' && next) {
|
if (queue.status === 'running' && next) {
|
||||||
return `Generating shot ${next.index + 1} of ${queue.totalCount}. ${queue.completedCount} saved.`
|
const pauseNote = queue.stopAfterCurrent ? ' Will pause after this shot.' : ''
|
||||||
|
return `Generating shot ${next.index + 1} of ${queue.totalCount}. ${queue.completedCount} saved.${pauseNote}`
|
||||||
}
|
}
|
||||||
if (queue.completedCount === 0) return `Not started · ${queue.totalCount} shots`
|
if (queue.completedCount === 0) return `Not started · ${queue.totalCount} shots`
|
||||||
if (next) return `Left off after shot ${queue.completedCount} of ${queue.totalCount}. Next is shot ${next.index + 1}.`
|
if (next) return `Left off after shot ${queue.completedCount} of ${queue.totalCount}. Next is shot ${next.index + 1}.`
|
||||||
@@ -379,9 +446,22 @@ function jobLine(job: StudioJobRow) {
|
|||||||
? `Next after the current shot · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'} · own clip, not part of the current sequence`
|
? `Next after the current shot · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'} · own clip, not part of the current sequence`
|
||||||
: `Waiting · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
: `Waiting · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
||||||
}
|
}
|
||||||
if (job.shots) return progressLine(job.shots)
|
if (job.shots) {
|
||||||
if (job.status === 'held') return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused'
|
const line = progressLine(job.shots)
|
||||||
if (job.status === 'running') return `Generating · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
if (job.pauseAfterCurrent && job.shots.status === 'running' && !job.shots.stopAfterCurrent) {
|
||||||
|
return `${line} Will pause after this shot.`
|
||||||
|
}
|
||||||
|
if (job.pausedByUser) return `${line} Paused after current.`
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
if (job.status === 'held') {
|
||||||
|
if (job.pausedByUser) return 'Paused after current. Next jobs will not start until you resume.'
|
||||||
|
return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused'
|
||||||
|
}
|
||||||
|
if (job.status === 'running') {
|
||||||
|
if (job.pauseAfterCurrent) return `Generating · will pause after this · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
||||||
|
return `Generating · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
return job.status
|
return job.status
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +533,7 @@ async function loadQueues() {
|
|||||||
jobs?: StudioJobRow[]
|
jobs?: StudioJobRow[]
|
||||||
orphans?: Queue[]
|
orphans?: Queue[]
|
||||||
waitingCount?: number
|
waitingCount?: number
|
||||||
|
paused?: boolean
|
||||||
}>('/api/studio-queue?full=1')
|
}>('/api/studio-queue?full=1')
|
||||||
const nextJobs = (data.jobs || []).map((job) => (
|
const nextJobs = (data.jobs || []).map((job) => (
|
||||||
job.shots ? { ...job, shots: mergeQueues([job.shots])[0] } : job
|
job.shots ? { ...job, shots: mergeQueues([job.shots])[0] } : job
|
||||||
@@ -460,6 +541,7 @@ async function loadQueues() {
|
|||||||
jobs.value = nextJobs
|
jobs.value = nextJobs
|
||||||
orphans.value = mergeQueues(data.orphans || [])
|
orphans.value = mergeQueues(data.orphans || [])
|
||||||
waitingCount.value = data.waitingCount || 0
|
waitingCount.value = data.waitingCount || 0
|
||||||
|
queuePaused.value = data.paused === true
|
||||||
pendingCount.value = [
|
pendingCount.value = [
|
||||||
...nextJobs.flatMap(job => job.shots ? [job.shots] : []),
|
...nextJobs.flatMap(job => job.shots ? [job.shots] : []),
|
||||||
...orphans.value
|
...orphans.value
|
||||||
@@ -542,9 +624,50 @@ async function process(queue: Queue, count: number | 'all') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pause(queue: Queue) {
|
function pauseToggleClass(pressed: boolean) {
|
||||||
await $fetch(`/api/queues/${queue.id}/pause`, { method: 'POST' }).catch(() => null)
|
return pressed
|
||||||
|
? 'bg-amber-400 text-zinc-950 shadow-[0_0_0_2px_rgba(251,191,36,0.55)]'
|
||||||
|
: 'border border-amber-300/50 text-amber-100 hover:border-amber-200 hover:bg-amber-400/10'
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPauseJob(job: StudioJobRow) {
|
||||||
|
return job.status === 'running' || job.shots?.status === 'running'
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobIsArmed(job: StudioJobRow) {
|
||||||
|
return Boolean(job.pauseAfterCurrent || job.shots?.stopAfterCurrent || (queuePaused.value && job.status === 'running'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePause(target?: { job?: StudioJobRow; queue?: Queue }) {
|
||||||
|
const armed = target?.job
|
||||||
|
? jobIsArmed(target.job)
|
||||||
|
: target?.queue
|
||||||
|
? Boolean(target.queue.stopAfterCurrent || queuePaused.value)
|
||||||
|
: pauseArmed.value
|
||||||
|
const resume = armed
|
||||||
|
if (target?.job) {
|
||||||
|
target.job.pauseAfterCurrent = !resume
|
||||||
|
if (target.job.shots) target.job.shots.stopAfterCurrent = !resume
|
||||||
|
}
|
||||||
|
if (target?.queue) target.queue.stopAfterCurrent = !resume
|
||||||
|
if (!target?.job && !target?.queue) queuePaused.value = !resume
|
||||||
|
busyId.value = 'pause'
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
if (target?.job) {
|
||||||
|
await $fetch(`/api/studio-queue/${target.job.id}/pause`, { method: 'POST', body: { resume } })
|
||||||
|
} else if (target?.queue) {
|
||||||
|
await $fetch(`/api/queues/${target.queue.id}/pause`, { method: 'POST', body: { resume } })
|
||||||
|
} else {
|
||||||
|
await $fetch('/api/studio-queue/pause', { method: 'POST', body: { resume } })
|
||||||
|
}
|
||||||
await loadQueues()
|
await loadQueues()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not update pause'
|
||||||
|
await loadQueues().catch(() => null)
|
||||||
|
} finally {
|
||||||
|
busyId.value = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(queue: Queue) {
|
async function remove(queue: Queue) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { copyFileSync, existsSync, readFileSync } from 'node:fs'
|
import { copyFileSync, existsSync, readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
import { composeShotPrompt } from '~/utils/globalLocks'
|
||||||
|
|
||||||
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
|
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
|
||||||
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
|
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
|
||||||
@@ -59,6 +60,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
const destFolder = publicLibrary(event).folders.find(folder => folder.id === source.folderId)
|
const destFolder = publicLibrary(event).folders.find(folder => folder.id === source.folderId)
|
||||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||||
const durationSeconds = parseExtendDuration(body?.duration)
|
const durationSeconds = parseExtendDuration(body?.duration)
|
||||||
|
const composedPrompt = composeShotPrompt({
|
||||||
|
globalLocks: source.globalLocks,
|
||||||
|
prompt,
|
||||||
|
shotIndex: (source.chainIndex || 0) + 1,
|
||||||
|
familyRefs: source.permanenceRefs
|
||||||
|
})
|
||||||
const turbo = source.turbo !== false
|
const turbo = source.turbo !== false
|
||||||
const fps = parseFps(source.fps)
|
const fps = parseFps(source.fps)
|
||||||
const cfg = parseCfg(source.cfg, turbo)
|
const cfg = parseCfg(source.cfg, turbo)
|
||||||
@@ -84,7 +91,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
hideThumbnail: source.hideThumbnail,
|
hideThumbnail: source.hideThumbnail,
|
||||||
folderLocked,
|
folderLocked,
|
||||||
name: clipName,
|
name: clipName,
|
||||||
prompt,
|
prompt: composedPrompt,
|
||||||
aspect: source.aspect || '16:9',
|
aspect: source.aspect || '16:9',
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -102,7 +109,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
familyId: source.familyId || crypto.randomUUID(),
|
familyId: source.familyId || crypto.randomUUID(),
|
||||||
parentClipId: source.id,
|
parentClipId: source.id,
|
||||||
chainIndex: (source.chainIndex || 0) + 1,
|
chainIndex: (source.chainIndex || 0) + 1,
|
||||||
workflow: parseVideoWorkflow(source.workflow)
|
workflow: parseVideoWorkflow(source.workflow),
|
||||||
|
globalLocks: source.globalLocks,
|
||||||
|
permanenceRefs: source.permanenceRefs
|
||||||
}
|
}
|
||||||
|
|
||||||
emitJob(job, { type: 'status', message: 'Extracting last frame & stitching extension...', progress: 2 })
|
emitJob(job, { type: 'status', message: 'Extracting last frame & stitching extension...', progress: 2 })
|
||||||
@@ -111,7 +120,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
sourcePath,
|
sourcePath,
|
||||||
part1Path,
|
part1Path,
|
||||||
framePath,
|
framePath,
|
||||||
prompt,
|
prompt: composedPrompt,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
steps,
|
steps,
|
||||||
@@ -236,7 +245,13 @@ async function runExtend(
|
|||||||
extendPart1Path: job.library.extendPart1Path,
|
extendPart1Path: job.library.extendPart1Path,
|
||||||
familyId: job.library.familyId,
|
familyId: job.library.familyId,
|
||||||
parentClipId: job.library.parentClipId,
|
parentClipId: job.library.parentClipId,
|
||||||
chainIndex: job.library.chainIndex
|
chainIndex: job.library.chainIndex,
|
||||||
|
stillId: job.library.stillId,
|
||||||
|
sound: job.library.sound,
|
||||||
|
workflow: job.library.workflow,
|
||||||
|
duration: job.library.duration,
|
||||||
|
globalLocks: job.library.globalLocks,
|
||||||
|
permanenceRefs: job.library.permanenceRefs
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||||
|
|||||||
@@ -1,5 +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'
|
||||||
|
|
||||||
function parseDuration(raw: unknown) {
|
function parseDuration(raw: unknown) {
|
||||||
const seconds = Number(raw)
|
const seconds = Number(raw)
|
||||||
@@ -12,14 +13,15 @@ function parseExtendDuration(raw: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseExtensions(raw: string | undefined) {
|
function parseExtensions(raw: string | undefined) {
|
||||||
if (!raw) return [] as { prompt: string; duration: number }[]
|
if (!raw) return [] as { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
if (!Array.isArray(parsed)) return []
|
if (!Array.isArray(parsed)) return []
|
||||||
return parsed
|
return parsed
|
||||||
.map((item: { prompt?: unknown; duration?: unknown }) => ({
|
.map((item: { prompt?: unknown; duration?: unknown; permanenceRefs?: unknown }) => ({
|
||||||
prompt: String(item?.prompt || '').trim(),
|
prompt: String(item?.prompt || '').trim(),
|
||||||
duration: parseExtendDuration(item?.duration)
|
duration: parseExtendDuration(item?.duration),
|
||||||
|
permanenceRefs: normalizePermanenceRefs(item?.permanenceRefs)
|
||||||
}))
|
}))
|
||||||
.filter(item => item.prompt)
|
.filter(item => item.prompt)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -27,6 +29,26 @@ function parseExtensions(raw: string | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseFamilyPermanenceRefs(raw: string | undefined) {
|
||||||
|
if (!raw) return [] as PermanenceRef[]
|
||||||
|
try {
|
||||||
|
return normalizePermanenceRefs(JSON.parse(raw))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseShotPermanenceRefs(raw: string | undefined, shotCount: number) {
|
||||||
|
if (!raw) return Array.from({ length: shotCount }, () => [] as PermanenceRef[])
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) return Array.from({ length: shotCount }, () => [] as PermanenceRef[])
|
||||||
|
return Array.from({ length: shotCount }, (_, index) => normalizePermanenceRefs(parsed[index]))
|
||||||
|
} catch {
|
||||||
|
return Array.from({ length: shotCount }, () => [] as PermanenceRef[])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
|
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
|
||||||
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
|
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
|
||||||
|
|
||||||
@@ -84,11 +106,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!image && !textToVideo) {
|
if (!image && !textToVideo) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
|
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
|
||||||
}
|
}
|
||||||
const prompt = (fields.prompt || '').trim()
|
const promptRaw = (fields.prompt || '').trim()
|
||||||
if (!prompt) {
|
if (!promptRaw) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
|
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
|
||||||
}
|
}
|
||||||
|
const resolvedLocks = resolveGlobalLocks(fields.globalLocks || '', promptRaw)
|
||||||
|
const prompt = resolvedLocks.prompt
|
||||||
|
const globalLocks = resolvedLocks.locks
|
||||||
const extensions = parseExtensions(fields.extensions)
|
const extensions = parseExtensions(fields.extensions)
|
||||||
|
const permanenceRefs = parseFamilyPermanenceRefs(fields.permanenceRefs)
|
||||||
|
const shotPermanenceRefs = parseShotPermanenceRefs(fields.shotPermanenceRefs, 1 + extensions.length)
|
||||||
|
for (const [index, item] of extensions.entries()) {
|
||||||
|
if (!shotPermanenceRefs[index + 1]?.length && item.permanenceRefs?.length) {
|
||||||
|
shotPermanenceRefs[index + 1] = item.permanenceRefs
|
||||||
|
}
|
||||||
|
}
|
||||||
const useIdentityRefs = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
|
const useIdentityRefs = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
|
||||||
const { width, height } = resolveOutputSize(
|
const { width, height } = resolveOutputSize(
|
||||||
fields.aspect,
|
fields.aspect,
|
||||||
@@ -180,7 +212,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
folderLocked,
|
folderLocked,
|
||||||
referenceStillIds,
|
referenceStillIds,
|
||||||
extensions,
|
extensions,
|
||||||
queueAutoRun
|
queueAutoRun,
|
||||||
|
globalLocks: globalLocks || undefined,
|
||||||
|
permanenceRefs: permanenceRefs.length ? permanenceRefs : undefined,
|
||||||
|
shotPermanenceRefs: shotPermanenceRefs.some(item => item.length) ? shotPermanenceRefs : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await kickStudioQueue()
|
await kickStudioQueue()
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const body = await readBody<{ folderId?: string; name?: string; family?: boolean }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean }))
|
const body = await readBody<{ folderId?: string; name?: string; family?: boolean; globalLocks?: string; permanenceRefs?: unknown }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean; globalLocks?: string; permanenceRefs?: unknown }))
|
||||||
const folderId = String(body?.folderId || '')
|
const folderId = String(body?.folderId || '')
|
||||||
const hasName = typeof body?.name === 'string'
|
const hasName = typeof body?.name === 'string'
|
||||||
const family = body?.family === true
|
const family = body?.family === true
|
||||||
if (!folderId && !hasName) {
|
const hasLocks = typeof body?.globalLocks === 'string'
|
||||||
|
const hasRefs = Array.isArray(body?.permanenceRefs)
|
||||||
|
if (!folderId && !hasName && !hasLocks && !hasRefs) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
|
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
|
||||||
}
|
}
|
||||||
const clip = getClip(owner, id)
|
const clip = getClip(owner, id)
|
||||||
@@ -19,5 +21,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (family) await renameClipFamily(owner, id, body.name || '')
|
if (family) await renameClipFamily(owner, id, body.name || '')
|
||||||
else await renameClip(owner, id, body.name || '')
|
else await renameClip(owner, id, body.name || '')
|
||||||
}
|
}
|
||||||
|
if (hasLocks || hasRefs) {
|
||||||
|
await patchClipMeta(owner, id, {
|
||||||
|
globalLocks: hasLocks ? body.globalLocks : undefined,
|
||||||
|
permanenceRefs: hasRefs ? body.permanenceRefs as import('~/utils/globalLocks').PermanenceRef[] : undefined,
|
||||||
|
family: family || hasLocks
|
||||||
|
})
|
||||||
|
}
|
||||||
return publicLibrary(event)
|
return publicLibrary(event)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { pauseShotQueue, summarizeQueue, getShotQueue } from '~/server/utils/shotQueue'
|
import { setShotQueuePause, summarizeQueue, getShotQueue } from '~/server/utils/shotQueue'
|
||||||
|
import { toggleStudioQueuePause } from '~/server/utils/studioQueue'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
await pauseShotQueue(owner, id)
|
const body = await readBody<{ resume?: boolean }>().catch(() => ({} as { resume?: boolean }))
|
||||||
const queue = getShotQueue(owner, id)
|
const queue = getShotQueue(owner, id)
|
||||||
if (!queue) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
if (!queue) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
||||||
return summarizeQueue(queue)
|
const result = await toggleStudioQueuePause(owner, {
|
||||||
|
resume: body?.resume,
|
||||||
|
queueId: id
|
||||||
|
})
|
||||||
|
const next = getShotQueue(owner, id)
|
||||||
|
if (!next) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
||||||
|
if (result.willPause && !next.stopAfterCurrent) {
|
||||||
|
await setShotQueuePause(owner, id, true)
|
||||||
|
}
|
||||||
|
if (!result.willPause && next.stopAfterCurrent) {
|
||||||
|
await setShotQueuePause(owner, id, false)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...summarizeQueue(getShotQueue(owner, id) || next),
|
||||||
|
paused: result.paused,
|
||||||
|
willPause: result.willPause
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { listShotQueues, summarizeQueue } from '~/server/utils/shotQueue'
|
import { listShotQueues, summarizeQueue } from '~/server/utils/shotQueue'
|
||||||
import { listStudioJobs, summarizeStudioJob } from '~/server/utils/studioQueue'
|
import { isStudioQueuePaused, listStudioJobs, summarizeStudioJob } from '~/server/utils/studioQueue'
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
@@ -35,6 +35,7 @@ export default defineEventHandler((event) => {
|
|||||||
.filter(queue => !claimed.has(queue.id) && queue.status !== 'complete')
|
.filter(queue => !claimed.has(queue.id) && queue.status !== 'complete')
|
||||||
.map(summarizeQueue)
|
.map(summarizeQueue)
|
||||||
: [],
|
: [],
|
||||||
waitingCount: jobs.filter(job => job.status === 'waiting').length
|
waitingCount: jobs.filter(job => job.status === 'waiting').length,
|
||||||
|
paused: isStudioQueuePaused(owner)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { toggleStudioQueuePause } from '~/server/utils/studioQueue'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
const body = await readBody<{ resume?: boolean }>().catch(() => ({} as { resume?: boolean }))
|
||||||
|
return toggleStudioQueuePause(owner, {
|
||||||
|
resume: body?.resume,
|
||||||
|
jobId: id
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { toggleStudioQueuePause } from '~/server/utils/studioQueue'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const body = await readBody<{ resume?: boolean; jobId?: string }>().catch(() => ({} as { resume?: boolean; jobId?: string }))
|
||||||
|
return toggleStudioQueuePause(owner, {
|
||||||
|
resume: body?.resume,
|
||||||
|
jobId: body?.jobId ? String(body.jobId) : undefined
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -18,6 +18,7 @@ export default defineNitroPlugin(() => {
|
|||||||
for (const queue of listShotQueues(owner)) {
|
for (const queue of listShotQueues(owner)) {
|
||||||
if (claimedQueueIds.has(queue.id)) continue
|
if (claimedQueueIds.has(queue.id)) continue
|
||||||
if (!queue.autoRun) continue
|
if (!queue.autoRun) continue
|
||||||
|
if (queue.stopAfterCurrent) continue
|
||||||
if (queue.status === 'complete') continue
|
if (queue.status === 'complete') continue
|
||||||
if (pendingQueueIds.has(queue.id)) continue
|
if (pendingQueueIds.has(queue.id)) continue
|
||||||
if (!queue.currentClipId || pendingSegmentCount(queue) === 0) continue
|
if (!queue.currentClipId || pendingSegmentCount(queue) === 0) continue
|
||||||
|
|||||||
@@ -71,10 +71,13 @@ export interface Job {
|
|||||||
referenceImageNames?: string[]
|
referenceImageNames?: string[]
|
||||||
referenceStillIds?: Array<string | null>
|
referenceStillIds?: Array<string | null>
|
||||||
useIdentityRefs?: boolean
|
useIdentityRefs?: boolean
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]
|
||||||
|
shotPermanenceRefs?: import('~/utils/globalLocks').PermanenceRef[][]
|
||||||
duration?: number
|
duration?: number
|
||||||
sound?: boolean
|
sound?: boolean
|
||||||
draftId?: string
|
draftId?: string
|
||||||
extensions?: { prompt: string; duration: number }[]
|
extensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }[]
|
||||||
chainIndex?: number
|
chainIndex?: number
|
||||||
chainStep?: number
|
chainStep?: number
|
||||||
chainTotal?: number
|
chainTotal?: number
|
||||||
|
|||||||
+49
-2
@@ -6,6 +6,7 @@ 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 } from '~/server/utils/ffmpeg'
|
import { extractFirstFrame, extractLastFrame } from '~/server/utils/ffmpeg'
|
||||||
|
import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
const scryptAsync = promisify(scrypt)
|
const scryptAsync = promisify(scrypt)
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ export interface LibraryClip {
|
|||||||
stillId?: string
|
stillId?: string
|
||||||
sound?: boolean
|
sound?: boolean
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: PermanenceRef[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StillRole = 'input' | 'output'
|
export type StillRole = 'input' | 'output'
|
||||||
@@ -78,6 +81,7 @@ export interface PublicFolder {
|
|||||||
export interface QueuedExtension {
|
export interface QueuedExtension {
|
||||||
prompt: string
|
prompt: string
|
||||||
duration: number
|
duration: number
|
||||||
|
permanenceRefs?: PermanenceRef[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RetryDraft {
|
export interface RetryDraft {
|
||||||
@@ -104,6 +108,9 @@ export interface RetryDraft {
|
|||||||
scheduler?: string
|
scheduler?: string
|
||||||
extensions?: QueuedExtension[]
|
extensions?: QueuedExtension[]
|
||||||
workflow?: import('~/utils/videoModels').VideoWorkflowId
|
workflow?: import('~/utils/videoModels').VideoWorkflowId
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: PermanenceRef[]
|
||||||
|
shotPermanenceRefs?: PermanenceRef[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Catalog {
|
interface Catalog {
|
||||||
@@ -1120,6 +1127,9 @@ export async function saveRetryDraft(params: {
|
|||||||
scheduler?: string
|
scheduler?: string
|
||||||
extensions?: QueuedExtension[]
|
extensions?: QueuedExtension[]
|
||||||
workflow?: import('~/utils/videoModels').VideoWorkflowId
|
workflow?: import('~/utils/videoModels').VideoWorkflowId
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: PermanenceRef[]
|
||||||
|
shotPermanenceRefs?: PermanenceRef[][]
|
||||||
}) {
|
}) {
|
||||||
return mutate(params.ownerKey, (catalog) => {
|
return mutate(params.ownerKey, (catalog) => {
|
||||||
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
|
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
|
||||||
@@ -1146,7 +1156,10 @@ export async function saveRetryDraft(params: {
|
|||||||
samplerName: params.samplerName,
|
samplerName: params.samplerName,
|
||||||
scheduler: params.scheduler,
|
scheduler: params.scheduler,
|
||||||
extensions: params.extensions?.length ? params.extensions : undefined,
|
extensions: params.extensions?.length ? params.extensions : undefined,
|
||||||
workflow: params.workflow
|
workflow: params.workflow,
|
||||||
|
globalLocks: params.globalLocks,
|
||||||
|
permanenceRefs: params.permanenceRefs,
|
||||||
|
shotPermanenceRefs: params.shotPermanenceRefs
|
||||||
}
|
}
|
||||||
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
|
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
|
||||||
return draft
|
return draft
|
||||||
@@ -1298,6 +1311,8 @@ export async function saveClip(params: {
|
|||||||
workflow?: import('~/utils/videoModels').VideoWorkflowId
|
workflow?: import('~/utils/videoModels').VideoWorkflowId
|
||||||
stillId?: string
|
stillId?: string
|
||||||
sound?: boolean
|
sound?: boolean
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: PermanenceRef[]
|
||||||
}) {
|
}) {
|
||||||
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]
|
||||||
@@ -1326,7 +1341,9 @@ export async function saveClip(params: {
|
|||||||
workflow: params.workflow,
|
workflow: params.workflow,
|
||||||
stillId: params.stillId,
|
stillId: params.stillId,
|
||||||
sound: params.sound,
|
sound: params.sound,
|
||||||
hideInput: params.hideInput === true
|
hideInput: params.hideInput === true,
|
||||||
|
globalLocks: params.globalLocks,
|
||||||
|
permanenceRefs: params.permanenceRefs?.length ? normalizePermanenceRefs(params.permanenceRefs) : undefined
|
||||||
}
|
}
|
||||||
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
||||||
const videoPath = clipVideoPath(params.ownerKey, clip.id)
|
const videoPath = clipVideoPath(params.ownerKey, clip.id)
|
||||||
@@ -1413,6 +1430,36 @@ export function moveClipFamily(owner: string, id: string, folderId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function patchClipMeta(owner: string, id: string, patch: {
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: PermanenceRef[]
|
||||||
|
family?: boolean
|
||||||
|
}) {
|
||||||
|
return mutate(owner, (catalog) => {
|
||||||
|
const clip = catalog.clips.find(item => item.id === id)
|
||||||
|
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||||
|
const family = patch.family ? clipsInSameFamily(catalog, clip) : [clip]
|
||||||
|
if (typeof patch.globalLocks === 'string') {
|
||||||
|
const locks = patch.globalLocks.trim()
|
||||||
|
for (const item of family) {
|
||||||
|
if (locks) item.globalLocks = locks
|
||||||
|
else delete item.globalLocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patch.permanenceRefs) {
|
||||||
|
const refs = normalizePermanenceRefs(patch.permanenceRefs)
|
||||||
|
if (patch.family) {
|
||||||
|
for (const item of family) {
|
||||||
|
if (refs.length) item.permanenceRefs = refs
|
||||||
|
else delete item.permanenceRefs
|
||||||
|
}
|
||||||
|
} else if (refs.length) clip.permanenceRefs = refs
|
||||||
|
else delete clip.permanenceRefs
|
||||||
|
}
|
||||||
|
return patch.family ? family : clip
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function renameClip(owner: string, id: string, name: string) {
|
export function renameClip(owner: string, id: string, name: string) {
|
||||||
const trimmed = name.trim().slice(0, 80)
|
const trimmed = name.trim().slice(0, 80)
|
||||||
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
||||||
|
|||||||
+16
-4
@@ -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 { mergePermanenceRefs } from '~/utils/globalLocks'
|
||||||
|
|
||||||
export interface PendingJob {
|
export interface PendingJob {
|
||||||
jobId: string
|
jobId: string
|
||||||
@@ -35,6 +36,9 @@ export interface PendingJob {
|
|||||||
referenceImageNames?: string[]
|
referenceImageNames?: string[]
|
||||||
referenceStillIds?: Array<string | null>
|
referenceStillIds?: Array<string | null>
|
||||||
useIdentityRefs?: boolean
|
useIdentityRefs?: boolean
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]
|
||||||
|
shotPermanenceRefs?: import('~/utils/globalLocks').PermanenceRef[][]
|
||||||
workflow?: import('~/utils/videoModels').VideoWorkflowId
|
workflow?: import('~/utils/videoModels').VideoWorkflowId
|
||||||
duration?: number
|
duration?: number
|
||||||
cfg?: number
|
cfg?: number
|
||||||
@@ -42,8 +46,8 @@ export interface PendingJob {
|
|||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
extensions?: { prompt: string; duration: number }[]
|
extensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }[]
|
||||||
remainingExtensions?: { prompt: string; duration: number }[]
|
remainingExtensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }[]
|
||||||
currentClipId?: string
|
currentClipId?: string
|
||||||
queueId?: string
|
queueId?: string
|
||||||
queueAutoRun?: boolean
|
queueAutoRun?: boolean
|
||||||
@@ -130,6 +134,9 @@ export function pendingFromJob(job: Job, overrides: Partial<PendingJob> = {}): P
|
|||||||
extensions: library.extensions,
|
extensions: library.extensions,
|
||||||
remainingExtensions: remaining,
|
remainingExtensions: remaining,
|
||||||
currentClipId: job.clipId,
|
currentClipId: job.clipId,
|
||||||
|
globalLocks: library.globalLocks,
|
||||||
|
permanenceRefs: library.permanenceRefs,
|
||||||
|
shotPermanenceRefs: library.shotPermanenceRefs,
|
||||||
queueId: library.queueId,
|
queueId: library.queueId,
|
||||||
queueAutoRun: library.queueAutoRun,
|
queueAutoRun: library.queueAutoRun,
|
||||||
queueBudget: library.queueBudget,
|
queueBudget: library.queueBudget,
|
||||||
@@ -177,7 +184,10 @@ export function libraryFromPending(pending: PendingJob): NonNullable<Job['librar
|
|||||||
extensions: pending.extensions || pending.remainingExtensions,
|
extensions: pending.extensions || pending.remainingExtensions,
|
||||||
queueId: pending.queueId,
|
queueId: pending.queueId,
|
||||||
queueAutoRun: pending.queueAutoRun,
|
queueAutoRun: pending.queueAutoRun,
|
||||||
queueBudget: pending.queueBudget
|
queueBudget: pending.queueBudget,
|
||||||
|
globalLocks: pending.globalLocks,
|
||||||
|
permanenceRefs: pending.permanenceRefs,
|
||||||
|
shotPermanenceRefs: pending.shotPermanenceRefs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +297,9 @@ export async function completePendingIfReady(pending: PendingJob) {
|
|||||||
parentClipId: pending.parentClipId,
|
parentClipId: pending.parentClipId,
|
||||||
chainIndex: pending.chainIndex,
|
chainIndex: pending.chainIndex,
|
||||||
stillId: pending.stillId,
|
stillId: pending.stillId,
|
||||||
sound: pending.sound
|
sound: pending.sound,
|
||||||
|
globalLocks: pending.globalLocks,
|
||||||
|
permanenceRefs: mergePermanenceRefs(pending.permanenceRefs, pending.shotPermanenceRefs?.[pending.chainIndex || 0])
|
||||||
})
|
})
|
||||||
await purgeComfyArtifacts({
|
await purgeComfyArtifacts({
|
||||||
video,
|
video,
|
||||||
|
|||||||
+49
-18
@@ -1,6 +1,6 @@
|
|||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { getJob, type Job } from '~/server/utils/jobs'
|
import { getJob, listJobs, type Job } from '~/server/utils/jobs'
|
||||||
|
|
||||||
export type ShotQueueStatus = 'idle' | 'running' | 'paused' | 'complete' | 'error'
|
export type ShotQueueStatus = 'idle' | 'running' | 'paused' | 'complete' | 'error'
|
||||||
export type ShotSegmentStatus = 'pending' | 'running' | 'complete' | 'error'
|
export type ShotSegmentStatus = 'pending' | 'running' | 'complete' | 'error'
|
||||||
@@ -12,6 +12,7 @@ export interface ShotQueueSegment {
|
|||||||
status: ShotSegmentStatus
|
status: ShotSegmentStatus
|
||||||
clipId?: string
|
clipId?: string
|
||||||
error?: string
|
error?: string
|
||||||
|
permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShotQueue {
|
export interface ShotQueue {
|
||||||
@@ -23,6 +24,7 @@ export interface ShotQueue {
|
|||||||
updatedAt: number
|
updatedAt: number
|
||||||
status: ShotQueueStatus
|
status: ShotQueueStatus
|
||||||
autoRun: boolean
|
autoRun: boolean
|
||||||
|
stopAfterCurrent?: boolean
|
||||||
currentClipId?: string
|
currentClipId?: string
|
||||||
currentJobId?: string
|
currentJobId?: string
|
||||||
stillId?: string
|
stillId?: string
|
||||||
@@ -42,6 +44,8 @@ export interface ShotQueue {
|
|||||||
sound?: boolean
|
sound?: boolean
|
||||||
useIdentityRefs?: boolean
|
useIdentityRefs?: boolean
|
||||||
referenceStillIds?: Array<string | null>
|
referenceStillIds?: Array<string | null>
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]
|
||||||
segments: ShotQueueSegment[]
|
segments: ShotQueueSegment[]
|
||||||
lastError?: string
|
lastError?: string
|
||||||
}
|
}
|
||||||
@@ -157,8 +161,10 @@ export async function createShotQueue(params: {
|
|||||||
sound?: boolean
|
sound?: boolean
|
||||||
useIdentityRefs?: boolean
|
useIdentityRefs?: boolean
|
||||||
referenceStillIds?: Array<string | null>
|
referenceStillIds?: Array<string | null>
|
||||||
initial: { prompt: string; duration: number }
|
globalLocks?: string
|
||||||
extensions: { prompt: string; duration: number }[]
|
permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]
|
||||||
|
initial: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }
|
||||||
|
extensions: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }[]
|
||||||
jobId?: string
|
jobId?: string
|
||||||
}): Promise<ShotQueue> {
|
}): Promise<ShotQueue> {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -171,6 +177,7 @@ export async function createShotQueue(params: {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
autoRun: params.autoRun,
|
autoRun: params.autoRun,
|
||||||
|
stopAfterCurrent: false,
|
||||||
currentJobId: params.jobId,
|
currentJobId: params.jobId,
|
||||||
stillId: params.stillId,
|
stillId: params.stillId,
|
||||||
stillFilename: params.stillFilename,
|
stillFilename: params.stillFilename,
|
||||||
@@ -189,13 +196,16 @@ export async function createShotQueue(params: {
|
|||||||
sound: params.sound,
|
sound: params.sound,
|
||||||
useIdentityRefs: params.useIdentityRefs,
|
useIdentityRefs: params.useIdentityRefs,
|
||||||
referenceStillIds: params.referenceStillIds,
|
referenceStillIds: params.referenceStillIds,
|
||||||
|
globalLocks: params.globalLocks,
|
||||||
|
permanenceRefs: params.permanenceRefs,
|
||||||
segments: [
|
segments: [
|
||||||
{ index: 0, prompt: params.initial.prompt, duration: params.initial.duration, status: 'running' },
|
{ index: 0, prompt: params.initial.prompt, duration: params.initial.duration, status: 'running', permanenceRefs: params.initial.permanenceRefs },
|
||||||
...params.extensions.map((item, index) => ({
|
...params.extensions.map((item, index) => ({
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
prompt: item.prompt,
|
prompt: item.prompt,
|
||||||
duration: item.duration,
|
duration: item.duration,
|
||||||
status: 'pending' as const
|
status: 'pending' as const,
|
||||||
|
permanenceRefs: item.permanenceRefs
|
||||||
}))
|
}))
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -305,6 +315,7 @@ export async function prepareQueueBurst(owner: string, id: string, count: number
|
|||||||
delete segment.error
|
delete segment.error
|
||||||
})
|
})
|
||||||
queue.autoRun = count === 'all'
|
queue.autoRun = count === 'all'
|
||||||
|
queue.stopAfterCurrent = false
|
||||||
queue.status = 'running'
|
queue.status = 'running'
|
||||||
queue.lastError = undefined
|
queue.lastError = undefined
|
||||||
queue.updatedAt = Date.now()
|
queue.updatedAt = Date.now()
|
||||||
@@ -341,23 +352,42 @@ export async function finishQueueBurst(owner: string, id: string, paused: boolea
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pauseShotQueue(owner: string, id: string) {
|
function liveJobForQueue(id: string): Job | undefined {
|
||||||
const jobId = activeJobs.get(id)
|
const mapped = activeJobs.get(id)
|
||||||
if (jobId) {
|
if (mapped) {
|
||||||
const job = getJob(jobId)
|
const job = getJob(mapped)
|
||||||
if (job?.library) job.library.stopAfterCurrent = true
|
if (job) return job
|
||||||
if (job?.library) job.library.queueAutoRun = false
|
}
|
||||||
|
return listJobs().find(job => (
|
||||||
|
job.library?.queueId === id
|
||||||
|
&& (job.status === 'queued' || job.status === 'uploading' || job.status === 'running')
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setShotQueuePause(owner: string, id: string, pause: boolean) {
|
||||||
|
const live = liveJobForQueue(id)
|
||||||
|
if (live?.library) {
|
||||||
|
live.library.stopAfterCurrent = pause
|
||||||
|
if (pause) live.library.queueAutoRun = false
|
||||||
}
|
}
|
||||||
return updateShotQueue(owner, id, (queue) => {
|
return updateShotQueue(owner, id, (queue) => {
|
||||||
|
if (pause) {
|
||||||
queue.autoRun = false
|
queue.autoRun = false
|
||||||
if (queue.status === 'running') {
|
queue.stopAfterCurrent = true
|
||||||
queue.lastError = undefined
|
if (queue.status === 'running') queue.lastError = undefined
|
||||||
} else if (queue.status !== 'complete') {
|
else if (queue.status !== 'complete') queue.status = 'paused'
|
||||||
queue.status = 'paused'
|
} else {
|
||||||
|
queue.stopAfterCurrent = false
|
||||||
|
if (live?.library) live.library.queueAutoRun = queue.autoRun === true
|
||||||
|
if (queue.status === 'paused') queue.status = 'idle'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pauseShotQueue(owner: string, id: string) {
|
||||||
|
return setShotQueuePause(owner, id, true)
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteShotQueue(owner: string, id: string) {
|
export async function deleteShotQueue(owner: string, id: string) {
|
||||||
if (activeJobs.has(id)) {
|
if (activeJobs.has(id)) {
|
||||||
throw createError({ statusCode: 409, statusMessage: 'Stop this batch before deleting it' })
|
throw createError({ statusCode: 409, statusMessage: 'Stop this batch before deleting it' })
|
||||||
@@ -395,10 +425,10 @@ export function listOwnersWithQueues() {
|
|||||||
.filter(owner => existsSync(queuesPath(owner)))
|
.filter(owner => existsSync(queuesPath(owner)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function remainingFromQueue(queue: ShotQueue): { prompt: string; duration: number }[] {
|
export function remainingFromQueue(queue: ShotQueue): { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }[] {
|
||||||
return queue.segments
|
return queue.segments
|
||||||
.filter(segment => segment.index > 0)
|
.filter(segment => segment.index > 0)
|
||||||
.map(segment => ({ prompt: segment.prompt, duration: segment.duration }))
|
.map(segment => ({ prompt: segment.prompt, duration: segment.duration, permanenceRefs: segment.permanenceRefs }))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lastCompletedIndex(queue: ShotQueue) {
|
export function lastCompletedIndex(queue: ShotQueue) {
|
||||||
@@ -411,6 +441,7 @@ export function liveSegmentPrompt(queue: ShotQueue, extensionIndex: number) {
|
|||||||
const segment = queue.segments.find(item => item.index === extensionIndex + 1)
|
const segment = queue.segments.find(item => item.index === extensionIndex + 1)
|
||||||
return {
|
return {
|
||||||
prompt: segment?.prompt || '',
|
prompt: segment?.prompt || '',
|
||||||
duration: segment?.duration || 5
|
duration: segment?.duration || 5,
|
||||||
|
permanenceRefs: segment?.permanenceRefs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+246
-32
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFile
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { getJob, listJobs, type Job } from '~/server/utils/jobs'
|
import { getJob, listJobs, type Job } from '~/server/utils/jobs'
|
||||||
import { parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
|
import { parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
|
||||||
|
import type { PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
||||||
|
|
||||||
@@ -29,8 +30,11 @@ export interface StudioJobPayload {
|
|||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
referenceStillIds: Array<string | null>
|
referenceStillIds: Array<string | null>
|
||||||
extensions: { prompt: string; duration: number }[]
|
extensions: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
|
||||||
queueAutoRun: boolean
|
queueAutoRun: boolean
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: PermanenceRef[]
|
||||||
|
shotPermanenceRefs?: PermanenceRef[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudioJob {
|
export interface StudioJob {
|
||||||
@@ -48,10 +52,17 @@ export interface StudioJob {
|
|||||||
payload: StudioJobPayload
|
payload: StudioJobPayload
|
||||||
cutIn?: boolean
|
cutIn?: boolean
|
||||||
holdForCutIn?: boolean
|
holdForCutIn?: boolean
|
||||||
|
pauseAfterCurrent?: boolean
|
||||||
|
pausedByUser?: boolean
|
||||||
resumeAutoRun?: boolean
|
resumeAutoRun?: boolean
|
||||||
lastError?: string
|
lastError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StudioQueueStore = {
|
||||||
|
paused: boolean
|
||||||
|
jobs: StudioJob[]
|
||||||
|
}
|
||||||
|
|
||||||
const writeChains = new Map<string, Promise<unknown>>()
|
const writeChains = new Map<string, Promise<unknown>>()
|
||||||
let dispatchChain: Promise<unknown> = Promise.resolve()
|
let dispatchChain: Promise<unknown> = Promise.resolve()
|
||||||
|
|
||||||
@@ -68,38 +79,68 @@ function ensureOwner(owner: string) {
|
|||||||
mkdirSync(join(libraryRoot(), 'users', owner), { recursive: true })
|
mkdirSync(join(libraryRoot(), 'users', owner), { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
function readJobs(owner: string): StudioJob[] {
|
function readStore(owner: string): StudioQueueStore {
|
||||||
ensureOwner(owner)
|
ensureOwner(owner)
|
||||||
const path = queuePath(owner)
|
const path = queuePath(owner)
|
||||||
if (!existsSync(path)) return []
|
if (!existsSync(path)) return { paused: false, jobs: [] }
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
||||||
return Array.isArray(parsed) ? parsed : []
|
if (Array.isArray(parsed)) return { paused: false, jobs: parsed }
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
return {
|
||||||
|
paused: parsed.paused === true,
|
||||||
|
jobs: Array.isArray(parsed.jobs) ? parsed.jobs : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { paused: false, jobs: [] }
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return { paused: false, jobs: [] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeJobs(owner: string, jobs: StudioJob[]) {
|
function writeStore(owner: string, store: StudioQueueStore) {
|
||||||
ensureOwner(owner)
|
ensureOwner(owner)
|
||||||
const path = queuePath(owner)
|
const path = queuePath(owner)
|
||||||
const tmp = `${path}.tmp`
|
const tmp = `${path}.tmp`
|
||||||
writeFileSync(tmp, JSON.stringify(jobs, null, 2))
|
writeFileSync(tmp, JSON.stringify({
|
||||||
|
paused: store.paused === true,
|
||||||
|
jobs: store.jobs
|
||||||
|
}, null, 2))
|
||||||
renameSync(tmp, path)
|
renameSync(tmp, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readJobs(owner: string): StudioJob[] {
|
||||||
|
return readStore(owner).jobs
|
||||||
|
}
|
||||||
|
|
||||||
function mutate<T>(owner: string, fn: (jobs: StudioJob[]) => T): Promise<T> {
|
function mutate<T>(owner: string, fn: (jobs: StudioJob[]) => T): Promise<T> {
|
||||||
const prev = writeChains.get(owner) || Promise.resolve()
|
const prev = writeChains.get(owner) || Promise.resolve()
|
||||||
const run = prev.then(() => {
|
const run = prev.then(() => {
|
||||||
const jobs = readJobs(owner)
|
const store = readStore(owner)
|
||||||
const result = fn(jobs)
|
const result = fn(store.jobs)
|
||||||
writeJobs(owner, jobs)
|
writeStore(owner, store)
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
writeChains.set(owner, run.then(() => undefined, () => undefined))
|
writeChains.set(owner, run.then(() => undefined, () => undefined))
|
||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mutateStore<T>(owner: string, fn: (store: StudioQueueStore) => T): Promise<T> {
|
||||||
|
const prev = writeChains.get(owner) || Promise.resolve()
|
||||||
|
const run = prev.then(() => {
|
||||||
|
const store = readStore(owner)
|
||||||
|
const result = fn(store)
|
||||||
|
writeStore(owner, store)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
writeChains.set(owner, run.then(() => undefined, () => undefined))
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStudioQueuePaused(owner: string) {
|
||||||
|
return readStore(owner).paused === true
|
||||||
|
}
|
||||||
|
|
||||||
export function listOwnersWithStudioQueues() {
|
export function listOwnersWithStudioQueues() {
|
||||||
const root = join(libraryRoot(), 'users')
|
const root = join(libraryRoot(), 'users')
|
||||||
if (!existsSync(root)) return [] as string[]
|
if (!existsSync(root)) return [] as string[]
|
||||||
@@ -133,6 +174,8 @@ export function summarizeStudioJob(job: StudioJob) {
|
|||||||
queueAutoRun: job.payload.queueAutoRun === true,
|
queueAutoRun: job.payload.queueAutoRun === true,
|
||||||
cutIn: job.cutIn === true,
|
cutIn: job.cutIn === true,
|
||||||
holdForCutIn: job.holdForCutIn === true,
|
holdForCutIn: job.holdForCutIn === true,
|
||||||
|
pauseAfterCurrent: job.pauseAfterCurrent === true,
|
||||||
|
pausedByUser: job.pausedByUser === true,
|
||||||
lastError: job.lastError
|
lastError: job.lastError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,11 +350,14 @@ function pickCutIn(jobs: StudioJob[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickHeld(jobs: StudioJob[]) {
|
function pickHeld(jobs: StudioJob[]) {
|
||||||
const held = jobs
|
return jobs
|
||||||
.filter(job => job.status === 'held' && job.shotQueueId)
|
.filter(job => (
|
||||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
job.status === 'held'
|
||||||
const cutInHold = held.find(job => job.holdForCutIn)
|
&& job.shotQueueId
|
||||||
return cutInHold || held[0] || null
|
&& job.holdForCutIn
|
||||||
|
&& !job.pausedByUser
|
||||||
|
))
|
||||||
|
.sort((a, b) => b.updatedAt - a.updatedAt)[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickWaiting(jobs: StudioJob[]) {
|
function pickWaiting(jobs: StudioJob[]) {
|
||||||
@@ -347,23 +393,27 @@ function repairStaleJobs(jobs: StudioJob[]) {
|
|||||||
async function dispatchStudioQueue() {
|
async function dispatchStudioQueue() {
|
||||||
if (videoJobsBusy()) return
|
if (videoJobsBusy()) return
|
||||||
for (const owner of listOwnersWithStudioQueues()) {
|
for (const owner of listOwnersWithStudioQueues()) {
|
||||||
const jobs = await mutate(owner, (list) => {
|
const store = await mutateStore(owner, (current) => {
|
||||||
repairStaleJobs(list)
|
repairStaleJobs(current.jobs)
|
||||||
const next = pruneDone(list)
|
const next = pruneDone(current.jobs)
|
||||||
list.splice(0, list.length, ...next)
|
current.jobs.splice(0, current.jobs.length, ...next)
|
||||||
return list.map(item => structuredClone(item))
|
return {
|
||||||
|
paused: current.paused === true,
|
||||||
|
jobs: current.jobs.map(item => structuredClone(item))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const cutIn = pickCutIn(jobs)
|
const cutIn = pickCutIn(store.jobs)
|
||||||
if (cutIn) {
|
if (cutIn) {
|
||||||
await startStudioJob(cutIn)
|
await startStudioJob(cutIn)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const held = pickHeld(jobs)
|
if (store.paused) return
|
||||||
|
const held = pickHeld(store.jobs)
|
||||||
if (held?.shotQueueId) {
|
if (held?.shotQueueId) {
|
||||||
await resumeHeldStudioJob(held)
|
await resumeHeldStudioJob(held)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const waiting = pickWaiting(jobs)
|
const waiting = pickWaiting(store.jobs)
|
||||||
if (waiting) {
|
if (waiting) {
|
||||||
await startStudioJob(waiting)
|
await startStudioJob(waiting)
|
||||||
return
|
return
|
||||||
@@ -445,7 +495,10 @@ export async function startStudioJob(item: StudioJob) {
|
|||||||
workflow: parseVideoWorkflow(payload.workflow),
|
workflow: parseVideoWorkflow(payload.workflow),
|
||||||
useIdentityRefs: payload.useIdentityRefs,
|
useIdentityRefs: payload.useIdentityRefs,
|
||||||
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,
|
||||||
|
permanenceRefs: payload.permanenceRefs,
|
||||||
|
shotPermanenceRefs: payload.shotPermanenceRefs
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extensions.length) {
|
if (extensions.length) {
|
||||||
@@ -472,7 +525,9 @@ export async function startStudioJob(item: StudioJob) {
|
|||||||
sound: payload.sound,
|
sound: payload.sound,
|
||||||
useIdentityRefs: payload.useIdentityRefs,
|
useIdentityRefs: payload.useIdentityRefs,
|
||||||
referenceStillIds: payload.referenceStillIds,
|
referenceStillIds: payload.referenceStillIds,
|
||||||
initial: { prompt: payload.prompt, duration: payload.duration },
|
globalLocks: payload.globalLocks,
|
||||||
|
permanenceRefs: payload.permanenceRefs,
|
||||||
|
initial: { prompt: payload.prompt, duration: payload.duration, permanenceRefs: payload.shotPermanenceRefs?.[0] },
|
||||||
extensions,
|
extensions,
|
||||||
jobId: job.id
|
jobId: job.id
|
||||||
})
|
})
|
||||||
@@ -549,22 +604,58 @@ export async function onLiveVideoSettled(job: Job) {
|
|||||||
const remaining = job.library
|
const remaining = job.library
|
||||||
? Math.max(0, (job.library.chainTotal || 1) - (job.library.chainStep || 1))
|
? Math.max(0, (job.library.chainTotal || 1) - (job.library.chainStep || 1))
|
||||||
: 0
|
: 0
|
||||||
const interrupted = job.library?.stopAfterCurrent === true
|
const failed = job.status === 'error' || job.status === 'cancelled'
|
||||||
&& remaining > 0
|
const snapshot = readStore(owner)
|
||||||
&& job.status !== 'error'
|
const rowNow = snapshot.jobs.find(item => item.liveJobId === job.id) || snapshot.jobs.find(item => item.status === 'running')
|
||||||
&& job.status !== 'cancelled'
|
const userPause = !failed && (
|
||||||
|
rowNow?.pauseAfterCurrent === true
|
||||||
|
|| (snapshot.paused && !rowNow?.holdForCutIn)
|
||||||
|
|| (job.library?.stopAfterCurrent === true && !rowNow?.holdForCutIn)
|
||||||
|
)
|
||||||
|
const cutInHold = !failed && remaining > 0 && rowNow?.holdForCutIn === true && !userPause
|
||||||
|
const interrupted = remaining > 0 && (userPause || cutInHold)
|
||||||
if (interrupted && job.status === 'running') {
|
if (interrupted && job.status === 'running') {
|
||||||
job.status = 'complete'
|
job.status = 'complete'
|
||||||
}
|
}
|
||||||
const hold = interrupted
|
if (userPause && remaining > 0) {
|
||||||
if (hold) {
|
await mutateStore(owner, (store) => {
|
||||||
|
store.paused = true
|
||||||
|
const row = store.jobs.find(item => item.liveJobId === job.id) || store.jobs.find(item => item.status === 'running')
|
||||||
|
if (!row) return
|
||||||
|
row.status = 'held'
|
||||||
|
row.liveJobId = undefined
|
||||||
|
row.pauseAfterCurrent = false
|
||||||
|
row.pausedByUser = true
|
||||||
|
row.holdForCutIn = false
|
||||||
|
row.resumeAutoRun = row.payload.queueAutoRun === true || row.resumeAutoRun === true
|
||||||
|
row.updatedAt = Date.now()
|
||||||
|
})
|
||||||
|
} else if (cutInHold) {
|
||||||
await mutate(owner, (jobs) => {
|
await mutate(owner, (jobs) => {
|
||||||
const row = jobs.find(item => item.liveJobId === job.id) || jobs.find(item => item.status === 'running')
|
const row = jobs.find(item => item.liveJobId === job.id) || jobs.find(item => item.status === 'running')
|
||||||
if (!row) return
|
if (!row) return
|
||||||
row.status = 'held'
|
row.status = 'held'
|
||||||
row.liveJobId = undefined
|
row.liveJobId = undefined
|
||||||
row.holdForCutIn = true
|
row.holdForCutIn = true
|
||||||
row.resumeAutoRun = row.payload.queueAutoRun === true || job.library?.queueAutoRun === true
|
row.resumeAutoRun = row.payload.queueAutoRun === true || job.library?.queueAutoRun === true || row.resumeAutoRun === true
|
||||||
|
row.updatedAt = Date.now()
|
||||||
|
})
|
||||||
|
} else if (userPause) {
|
||||||
|
await mutateStore(owner, (store) => {
|
||||||
|
store.paused = true
|
||||||
|
const row = store.jobs.find(item => item.liveJobId === job.id) || store.jobs.find(item => item.status === 'running')
|
||||||
|
if (!row) return
|
||||||
|
row.status = job.status === 'cancelled'
|
||||||
|
? 'cancelled'
|
||||||
|
: job.status === 'complete'
|
||||||
|
? 'complete'
|
||||||
|
: 'error'
|
||||||
|
row.liveJobId = undefined
|
||||||
|
row.cutIn = false
|
||||||
|
row.holdForCutIn = false
|
||||||
|
row.pauseAfterCurrent = false
|
||||||
|
row.pausedByUser = false
|
||||||
|
row.lastError = job.error
|
||||||
row.updatedAt = Date.now()
|
row.updatedAt = Date.now()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -577,3 +668,126 @@ export async function onLiveVideoSettled(job: Job) {
|
|||||||
}
|
}
|
||||||
kickStudioQueue()
|
kickStudioQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyLiveJobPause(live: Job | undefined, pause: boolean, restoreAutoRun?: boolean) {
|
||||||
|
if (!live?.library) return
|
||||||
|
live.library.stopAfterCurrent = pause
|
||||||
|
if (pause) live.library.queueAutoRun = false
|
||||||
|
else if (restoreAutoRun) live.library.queueAutoRun = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleStudioQueuePause(owner: string, options: {
|
||||||
|
resume?: boolean
|
||||||
|
jobId?: string
|
||||||
|
queueId?: string
|
||||||
|
} = {}) {
|
||||||
|
const { pauseShotQueue, setShotQueuePause, listShotQueues, getShotQueue } = await import('~/server/utils/shotQueue')
|
||||||
|
const storeNow = readStore(owner)
|
||||||
|
const running = storeNow.jobs.find(job => job.status === 'running')
|
||||||
|
const target = options.jobId
|
||||||
|
? storeNow.jobs.find(job => job.id === options.jobId)
|
||||||
|
: running || storeNow.jobs.find(job => job.pauseAfterCurrent || job.pausedByUser)
|
||||||
|
const queueArmed = options.queueId
|
||||||
|
? listShotQueues(owner).some(queue => queue.id === options.queueId && queue.stopAfterCurrent === true)
|
||||||
|
: false
|
||||||
|
const armed = target?.pauseAfterCurrent === true || queueArmed || (storeNow.paused && !running)
|
||||||
|
const shouldPause = options.resume === true ? false : options.resume === false ? true : !armed
|
||||||
|
|
||||||
|
if (!shouldPause) {
|
||||||
|
const heldPaused = storeNow.jobs.filter(job => (
|
||||||
|
job.status === 'held'
|
||||||
|
&& Boolean(job.shotQueueId)
|
||||||
|
&& job.pausedByUser === true
|
||||||
|
&& (!options.jobId || job.id === options.jobId)
|
||||||
|
))
|
||||||
|
const restored = await mutateStore(owner, (store) => {
|
||||||
|
store.paused = false
|
||||||
|
for (const job of store.jobs) {
|
||||||
|
if (options.jobId && job.id !== options.jobId) continue
|
||||||
|
job.pauseAfterCurrent = false
|
||||||
|
job.pausedByUser = false
|
||||||
|
}
|
||||||
|
if (!options.jobId) {
|
||||||
|
for (const job of store.jobs) {
|
||||||
|
job.pauseAfterCurrent = false
|
||||||
|
job.pausedByUser = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return structuredClone(store)
|
||||||
|
})
|
||||||
|
const rows = options.jobId
|
||||||
|
? restored.jobs.filter(job => job.id === options.jobId)
|
||||||
|
: restored.jobs
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.liveJobId) applyLiveJobPause(getJob(row.liveJobId), false, row.payload.queueAutoRun === true || row.resumeAutoRun === true)
|
||||||
|
if (row.shotQueueId) await setShotQueuePause(owner, row.shotQueueId, false).catch(() => null)
|
||||||
|
}
|
||||||
|
if (!options.jobId) {
|
||||||
|
for (const queue of listShotQueues(owner)) {
|
||||||
|
if (queue.stopAfterCurrent) await setShotQueuePause(owner, queue.id, false).catch(() => null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (heldPaused[0] && !videoJobsBusy()) {
|
||||||
|
await resumeHeldStudioJob(heldPaused[0]).catch(() => null)
|
||||||
|
} else {
|
||||||
|
kickStudioQueue()
|
||||||
|
}
|
||||||
|
return { paused: false, willPause: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await mutateStore(owner, (store) => {
|
||||||
|
store.paused = true
|
||||||
|
const row = options.jobId
|
||||||
|
? store.jobs.find(job => job.id === options.jobId)
|
||||||
|
: store.jobs.find(job => job.status === 'running')
|
||||||
|
if (row) {
|
||||||
|
if (row.status !== 'running' && row.status !== 'held') {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'Nothing is generating on that job' })
|
||||||
|
}
|
||||||
|
row.pauseAfterCurrent = row.status === 'running'
|
||||||
|
row.resumeAutoRun = row.payload.queueAutoRun === true || row.resumeAutoRun === true
|
||||||
|
row.updatedAt = Date.now()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
paused: store.paused,
|
||||||
|
job: row ? structuredClone(row) : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (updated.job?.liveJobId) {
|
||||||
|
applyLiveJobPause(getJob(updated.job.liveJobId), true)
|
||||||
|
}
|
||||||
|
const queueId = updated.job?.shotQueueId || options.queueId
|
||||||
|
const queued = queueId ? getShotQueue(owner, queueId) : null
|
||||||
|
if (updated.job && queued?.autoRun) {
|
||||||
|
await patchStudioJob(owner, updated.job.id, (job) => {
|
||||||
|
job.resumeAutoRun = true
|
||||||
|
}).catch(() => null)
|
||||||
|
}
|
||||||
|
if (queueId) await pauseShotQueue(owner, queueId).catch(() => null)
|
||||||
|
if (!updated.job && !queueId) {
|
||||||
|
for (const queue of listShotQueues(owner)) {
|
||||||
|
if (queue.status === 'running') await pauseShotQueue(owner, queue.id).catch(() => null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const anyGenerating = storeNow.jobs.some(job => job.status === 'running')
|
||||||
|
|| listShotQueues(owner).some(queue => queue.status === 'running')
|
||||||
|
if (!updated.job && !queueId && !anyGenerating) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'Nothing is generating, so there is nothing to pause after' })
|
||||||
|
}
|
||||||
|
return { paused: true, willPause: true, jobId: updated.job?.id, queueId }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onStudioQueueBurstStarted(owner: string, queueId: string, liveJobId: string) {
|
||||||
|
await mutateStore(owner, (store) => {
|
||||||
|
const row = store.jobs.find(job => job.shotQueueId === queueId)
|
||||||
|
if (!row) return
|
||||||
|
store.paused = false
|
||||||
|
row.status = 'running'
|
||||||
|
row.liveJobId = liveJobId
|
||||||
|
row.pauseAfterCurrent = false
|
||||||
|
row.pausedByUser = false
|
||||||
|
row.holdForCutIn = false
|
||||||
|
row.updatedAt = Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+53
-10
@@ -9,7 +9,7 @@ import { isLtxWorkflow, isTextToVideo, parseVideoWorkflow, workflowForExtension,
|
|||||||
import { clipVideoPath, clipTitle, deleteRetryDraft, extendTempDir, getClip, nextClipPartName, removeExtendTemp, stillPath } from '~/server/utils/library'
|
import { clipVideoPath, clipTitle, deleteRetryDraft, extendTempDir, getClip, nextClipPartName, removeExtendTemp, stillPath } from '~/server/utils/library'
|
||||||
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
|
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
|
||||||
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
|
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
|
||||||
import { onLiveVideoSettled } from '~/server/utils/studioQueue'
|
import { onLiveVideoSettled, onStudioQueueBurstStarted } from '~/server/utils/studioQueue'
|
||||||
import {
|
import {
|
||||||
finishQueueBurst,
|
finishQueueBurst,
|
||||||
getShotQueue,
|
getShotQueue,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
setQueueJob,
|
setQueueJob,
|
||||||
updateShotQueue
|
updateShotQueue
|
||||||
} from '~/server/utils/shotQueue'
|
} from '~/server/utils/shotQueue'
|
||||||
|
import { composeShotPrompt, type PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
export type ChainImage = { filename: string; data: Buffer; type?: string }
|
export type ChainImage = { filename: string; data: Buffer; type?: string }
|
||||||
|
|
||||||
@@ -42,6 +43,9 @@ type VideoChainParams = {
|
|||||||
duration: number
|
duration: number
|
||||||
useIdentityRefs: boolean
|
useIdentityRefs: boolean
|
||||||
referenceImages: Array<ChainImage | null>
|
referenceImages: Array<ChainImage | null>
|
||||||
|
globalLocks?: string
|
||||||
|
permanenceRefs?: PermanenceRef[]
|
||||||
|
shotPermanenceRefs?: PermanenceRef[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
@@ -107,7 +111,10 @@ function paramsFromJob(job: Job): VideoChainParams {
|
|||||||
workflow,
|
workflow,
|
||||||
duration,
|
duration,
|
||||||
useIdentityRefs: library.useIdentityRefs === true,
|
useIdentityRefs: library.useIdentityRefs === true,
|
||||||
referenceImages
|
referenceImages,
|
||||||
|
globalLocks: library.globalLocks,
|
||||||
|
permanenceRefs: library.permanenceRefs,
|
||||||
|
shotPermanenceRefs: library.shotPermanenceRefs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,8 +165,17 @@ export async function queueMiniMax(
|
|||||||
emitChainJob(job, { type: 'status', message: queueing, progress: 6 })
|
emitChainJob(job, { type: 'status', message: queueing, progress: 6 })
|
||||||
await waitForComfySocket(job, 4000)
|
await waitForComfySocket(job, 4000)
|
||||||
|
|
||||||
const graph = buildWorkflow({
|
const shotIndex = job.library?.chainIndex || 0
|
||||||
|
const composedPrompt = composeShotPrompt({
|
||||||
|
globalLocks: job.library?.globalLocks || params.globalLocks,
|
||||||
prompt: params.prompt,
|
prompt: params.prompt,
|
||||||
|
shotIndex,
|
||||||
|
familyRefs: job.library?.permanenceRefs || params.permanenceRefs,
|
||||||
|
shotRefs: job.library?.shotPermanenceRefs?.[shotIndex] || params.shotPermanenceRefs?.[shotIndex]
|
||||||
|
})
|
||||||
|
|
||||||
|
const graph = buildWorkflow({
|
||||||
|
prompt: composedPrompt,
|
||||||
imageName: uploaded.name,
|
imageName: uploaded.name,
|
||||||
width: params.width,
|
width: params.width,
|
||||||
height: params.height,
|
height: params.height,
|
||||||
@@ -258,6 +274,9 @@ export async function continueQueuedExtensions(
|
|||||||
const liveQueue = job.library.queueId
|
const liveQueue = job.library.queueId
|
||||||
? getShotQueue(job.library.ownerKey, job.library.queueId)
|
? getShotQueue(job.library.ownerKey, job.library.queueId)
|
||||||
: null
|
: null
|
||||||
|
if (liveQueue?.stopAfterCurrent) job.library.stopAfterCurrent = true
|
||||||
|
if (liveQueue?.autoRun === false) job.library.queueAutoRun = false
|
||||||
|
if (job.library.stopAfterCurrent) break
|
||||||
const live = liveQueue ? liveSegmentPrompt(liveQueue, i) : extensions[i]
|
const live = liveQueue ? liveSegmentPrompt(liveQueue, i) : extensions[i]
|
||||||
const ext = {
|
const ext = {
|
||||||
prompt: live.prompt || extensions[i].prompt,
|
prompt: live.prompt || extensions[i].prompt,
|
||||||
@@ -267,6 +286,17 @@ export async function continueQueuedExtensions(
|
|||||||
throw new Error(`Shot ${i + 2} needs a prompt`)
|
throw new Error(`Shot ${i + 2} needs a prompt`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
||||||
|
await sleep(3000)
|
||||||
|
assertJobActive(job)
|
||||||
|
const pausedQueue = job.library.queueId
|
||||||
|
? getShotQueue(job.library.ownerKey, job.library.queueId)
|
||||||
|
: null
|
||||||
|
if (job.library.stopAfterCurrent || pausedQueue?.stopAfterCurrent) {
|
||||||
|
job.library.stopAfterCurrent = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
const remainingAfter = extensions.length - (i + 1)
|
const remainingAfter = extensions.length - (i + 1)
|
||||||
const lastOfChain = remainingAfter === 0
|
const lastOfChain = remainingAfter === 0
|
||||||
const lastOfBurst = !autoRun && shotsLeft === 1
|
const lastOfBurst = !autoRun && shotsLeft === 1
|
||||||
@@ -290,10 +320,6 @@ export async function continueQueuedExtensions(
|
|||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
}
|
}
|
||||||
|
|
||||||
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
|
||||||
await sleep(3000)
|
|
||||||
assertJobActive(job)
|
|
||||||
|
|
||||||
copyFileSync(currentPath, part1Path)
|
copyFileSync(currentPath, part1Path)
|
||||||
try {
|
try {
|
||||||
await extractLastFrame(part1Path, framePath)
|
await extractLastFrame(part1Path, framePath)
|
||||||
@@ -306,6 +332,11 @@ export async function continueQueuedExtensions(
|
|||||||
const frame = readFileSync(framePath)
|
const frame = readFileSync(framePath)
|
||||||
job.library.extendPart1Path = part1Path
|
job.library.extendPart1Path = part1Path
|
||||||
job.library.prompt = ext.prompt
|
job.library.prompt = ext.prompt
|
||||||
|
if (live.permanenceRefs?.length) {
|
||||||
|
const refs = [...(job.library.shotPermanenceRefs || [])]
|
||||||
|
refs[i + 1] = live.permanenceRefs
|
||||||
|
job.library.shotPermanenceRefs = refs
|
||||||
|
}
|
||||||
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)
|
||||||
|
|
||||||
@@ -367,11 +398,13 @@ export async function continueQueuedExtensions(
|
|||||||
export async function continueQueuedExtensionsIfNeeded(job: Job) {
|
export async function continueQueuedExtensionsIfNeeded(job: Job) {
|
||||||
if (!job.library) return
|
if (!job.library) return
|
||||||
if (job.status === 'error' || job.status === 'cancelled' || job.status === 'complete') return
|
if (job.status === 'error' || job.status === 'cancelled' || job.status === 'complete') return
|
||||||
|
if (job.library.stopAfterCurrent) return
|
||||||
const remaining = remainingAfterCurrentShot(job.library)
|
const remaining = remainingAfterCurrentShot(job.library)
|
||||||
if (!remaining.length) return
|
if (!remaining.length) return
|
||||||
const queue = job.library.queueId
|
const queue = job.library.queueId
|
||||||
? getShotQueue(job.library.ownerKey, job.library.queueId)
|
? getShotQueue(job.library.ownerKey, job.library.queueId)
|
||||||
: null
|
: null
|
||||||
|
if (queue?.stopAfterCurrent) return
|
||||||
const autoRun = queue ? queue.autoRun === true : job.library.queueAutoRun === true
|
const autoRun = queue ? queue.autoRun === true : job.library.queueAutoRun === true
|
||||||
const budget = job.library.queueBudget || 0
|
const budget = job.library.queueBudget || 0
|
||||||
if (queue && !autoRun && budget <= 0) return
|
if (queue && !autoRun && budget <= 0) return
|
||||||
@@ -407,12 +440,17 @@ export async function runGeneration(job: Job, params: VideoChainParams) {
|
|||||||
persist: extensions.length === 0 || !autoRun
|
persist: extensions.length === 0 || !autoRun
|
||||||
})
|
})
|
||||||
|
|
||||||
if (job.library?.queueId && !autoRun) {
|
const stopNow = job.library?.stopAfterCurrent === true
|
||||||
|
|| (job.library?.queueId
|
||||||
|
? getShotQueue(job.library.ownerKey, job.library.queueId)?.stopAfterCurrent === true
|
||||||
|
: false)
|
||||||
|
const keepGoing = Boolean(autoRun && !stopNow && extensions.length && job.library)
|
||||||
|
if (job.library?.queueId && !keepGoing) {
|
||||||
await finishQueueBurst(job.library.ownerKey, job.library.queueId, true).catch(() => null)
|
await finishQueueBurst(job.library.ownerKey, job.library.queueId, true).catch(() => null)
|
||||||
setQueueJob(job.library.queueId, null)
|
setQueueJob(job.library.queueId, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!extensions.length || !job.library || !autoRun) return
|
if (!keepGoing) return
|
||||||
await continueQueuedExtensions(job, params, { maxShots: extensions.length, autoRun: true })
|
await continueQueuedExtensions(job, params, { maxShots: extensions.length, autoRun: true })
|
||||||
} finally {
|
} finally {
|
||||||
await onLiveVideoSettled(job)
|
await onLiveVideoSettled(job)
|
||||||
@@ -467,13 +505,18 @@ export async function startQueueBurst(owner: string, queueId: string, count: num
|
|||||||
useIdentityRefs: queue.useIdentityRefs,
|
useIdentityRefs: queue.useIdentityRefs,
|
||||||
queueId: queue.id,
|
queueId: queue.id,
|
||||||
queueAutoRun: count === 'all',
|
queueAutoRun: count === 'all',
|
||||||
queueBudget: n
|
queueBudget: n,
|
||||||
|
globalLocks: queue.globalLocks,
|
||||||
|
permanenceRefs: queue.permanenceRefs,
|
||||||
|
shotPermanenceRefs: queue.segments.map(segment => segment.permanenceRefs || [])
|
||||||
}
|
}
|
||||||
setQueueJob(queue.id, job.id)
|
setQueueJob(queue.id, job.id)
|
||||||
await updateShotQueue(owner, queue.id, (next) => {
|
await updateShotQueue(owner, queue.id, (next) => {
|
||||||
next.currentJobId = job.id
|
next.currentJobId = job.id
|
||||||
next.status = 'running'
|
next.status = 'running'
|
||||||
|
next.stopAfterCurrent = false
|
||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
|
await onStudioQueueBurstStarted(owner, queue.id, job.id).catch(() => null)
|
||||||
|
|
||||||
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
||||||
void continueQueuedExtensions(job, paramsFromJob(job), {
|
void continueQueuedExtensions(job, paramsFromJob(job), {
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { getJob, restoreJob } from '~/server/utils/jobs'
|
|||||||
import type { PendingJob } from '~/server/utils/pending'
|
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 { mergePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
|
function clipRefsFromJob(library: NonNullable<Job['library']>): PermanenceRef[] | undefined {
|
||||||
|
const refs = mergePermanenceRefs(library.permanenceRefs, library.shotPermanenceRefs?.[library.chainIndex || 0])
|
||||||
|
return refs.length ? refs : undefined
|
||||||
|
}
|
||||||
|
|
||||||
function classifyError(message: string) {
|
function classifyError(message: string) {
|
||||||
const lower = message.toLowerCase()
|
const lower = message.toLowerCase()
|
||||||
@@ -206,7 +212,9 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
|||||||
chainIndex: job.library.chainIndex,
|
chainIndex: job.library.chainIndex,
|
||||||
workflow: job.library.workflow,
|
workflow: job.library.workflow,
|
||||||
stillId: job.library.stillId,
|
stillId: job.library.stillId,
|
||||||
sound: job.library.sound
|
sound: job.library.sound,
|
||||||
|
globalLocks: job.library.globalLocks,
|
||||||
|
permanenceRefs: clipRefsFromJob(job.library)
|
||||||
})
|
})
|
||||||
job.clipId = clip.id
|
job.clipId = clip.id
|
||||||
job.savedPromptId = job.promptId
|
job.savedPromptId = job.promptId
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
export const PERMANENCE_REF_LABELS = ['Subject', 'Vehicle', 'Interior', 'Object'] as const
|
||||||
|
|
||||||
|
export type PermanenceRefLabel = (typeof PERMANENCE_REF_LABELS)[number]
|
||||||
|
|
||||||
|
export interface PermanenceRef {
|
||||||
|
stillId: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_GLOBAL_LOCKS = `[GLOBAL LOCKS]
|
||||||
|
Subject:
|
||||||
|
Vehicle:
|
||||||
|
Interior:
|
||||||
|
Object:
|
||||||
|
Extras:
|
||||||
|
Camera:
|
||||||
|
Speech:
|
||||||
|
Never:
|
||||||
|
State:`
|
||||||
|
|
||||||
|
const LOCK_FIELD = /^(Subject|Vehicle|Interior|Object|Extras|Camera|Speech|Never|State)\s*:?\s*/i
|
||||||
|
|
||||||
|
export function normalizePermanenceRefs(raw: unknown, limit = 8): PermanenceRef[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const refs: PermanenceRef[] = []
|
||||||
|
for (const item of raw) {
|
||||||
|
if (refs.length >= limit) break
|
||||||
|
const stillId = String((item && (item as { stillId?: unknown }).stillId) || '').trim()
|
||||||
|
if (!stillId || seen.has(stillId)) continue
|
||||||
|
const label = sanitizeRefLabel((item as { label?: unknown })?.label)
|
||||||
|
seen.add(stillId)
|
||||||
|
refs.push({ stillId, label })
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeRefLabel(raw: unknown) {
|
||||||
|
const label = String(raw || '').trim().slice(0, 24)
|
||||||
|
if (!label) return 'Object'
|
||||||
|
const match = PERMANENCE_REF_LABELS.find(item => item.toLowerCase() === label.toLowerCase())
|
||||||
|
return match || label
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextPermanenceLabel(existing: PermanenceRef[]) {
|
||||||
|
const used = new Set(existing.map(item => item.label))
|
||||||
|
return PERMANENCE_REF_LABELS.find(label => !used.has(label)) || 'Object'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergePermanenceRefs(family?: PermanenceRef[] | null, shot?: PermanenceRef[] | null) {
|
||||||
|
return normalizePermanenceRefs([...(family || []), ...(shot || [])])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlankGlobalLocks(text: string) {
|
||||||
|
const stripped = String(text || '')
|
||||||
|
.replace(/\[GLOBAL LOCKS\]/gi, '')
|
||||||
|
.replace(/\[INHERITS\]/gi, '')
|
||||||
|
.replace(LOCK_FIELD, '')
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.replace(LOCK_FIELD, '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
return !stripped
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksAlreadyInherited(prompt: string) {
|
||||||
|
return /^\s*\[(GLOBAL LOCKS|INHERITS)\]/i.test(String(prompt || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractGlobalLocks(text: string): { locks: string; script: string } {
|
||||||
|
const src = String(text || '').replace(/\r\n/g, '\n')
|
||||||
|
const marker = /\[GLOBAL LOCKS\]/i.exec(src)
|
||||||
|
if (!marker || marker.index === undefined) return { locks: '', script: src.trim() }
|
||||||
|
const after = src.slice(marker.index)
|
||||||
|
const shot = /^[ \t]*shot\s+\d+\b/im.exec(after)
|
||||||
|
if (!shot || shot.index === undefined) return { locks: '', script: src.trim() }
|
||||||
|
const locks = after.slice(0, shot.index).replace(/\n\[INHERITS\]\s*$/i, '').trim()
|
||||||
|
const script = `${src.slice(0, marker.index)}${after.slice(shot.index)}`.trim()
|
||||||
|
return isBlankGlobalLocks(locks) ? { locks: '', script: src.trim() } : { locks, script }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGlobalLocks(locks: string, firstPrompt: string) {
|
||||||
|
if (!isBlankGlobalLocks(locks)) return { locks: String(locks || '').trim(), prompt: firstPrompt }
|
||||||
|
const extracted = extractGlobalLocks(firstPrompt)
|
||||||
|
return extracted.locks
|
||||||
|
? { locks: extracted.locks, prompt: extracted.script }
|
||||||
|
: { locks: '', prompt: firstPrompt }
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractState(locks: string) {
|
||||||
|
const match = String(locks || '').match(/(?:^|\n)\s*State:\s*(.+?)(?=\n\s*(?:Subject|Vehicle|Interior|Object|Extras|Camera|Speech|Never)\s*:|\n\s*\[|$)/is)
|
||||||
|
return String(match?.[1] || '').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function permanenceLine(refs: PermanenceRef[]) {
|
||||||
|
const labels = [...new Set(refs.map(item => item.label).filter(Boolean))]
|
||||||
|
if (!labels.length) return ''
|
||||||
|
return `Permanence: ${labels.join(', ')} stay the same.`
|
||||||
|
}
|
||||||
|
|
||||||
|
function inheritLine(locks: string, refs: PermanenceRef[]) {
|
||||||
|
const parts = ['[INHERITS] Global locks.']
|
||||||
|
const labels = [...new Set(refs.map(item => item.label).filter(Boolean))]
|
||||||
|
if (labels.length) parts.push(`Same ${labels.join(', ')} as locked.`)
|
||||||
|
const state = extractState(locks)
|
||||||
|
if (state) parts.push(`State: ${state}`)
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyGlobalLocks(params: {
|
||||||
|
locks: string
|
||||||
|
prompt: string
|
||||||
|
shotIndex: number
|
||||||
|
refs?: PermanenceRef[]
|
||||||
|
}) {
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
return `${inheritLine(blank ? '' : locks, refs)}\n\n${beat}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composeShotPrompt(params: {
|
||||||
|
globalLocks?: string
|
||||||
|
prompt: string
|
||||||
|
shotIndex: number
|
||||||
|
familyRefs?: PermanenceRef[] | null
|
||||||
|
shotRefs?: PermanenceRef[] | null
|
||||||
|
}) {
|
||||||
|
return applyGlobalLocks({
|
||||||
|
locks: params.globalLocks || '',
|
||||||
|
prompt: params.prompt,
|
||||||
|
shotIndex: params.shotIndex,
|
||||||
|
refs: mergePermanenceRefs(params.familyRefs, params.shotRefs)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user