Give video extensions the same pre-text, prompt, and post-text fields as the main form.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+88
-48
@@ -522,11 +522,12 @@
|
|||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<PromptField
|
<PromptPartsEditor
|
||||||
v-model="item.prompt"
|
v-model:pre="item.promptPre"
|
||||||
:rows="3"
|
v-model:prompt="item.prompt"
|
||||||
:title="`Extension #${index + 1}`"
|
v-model:post="item.promptPost"
|
||||||
placeholder="Motion prompt for this extension…"
|
prompt-placeholder="Motion prompt for this extension…"
|
||||||
|
:prompt-rows="4"
|
||||||
:keep-items="keepPrompts.video"
|
:keep-items="keepPrompts.video"
|
||||||
keep-heading="Video prompts"
|
keep-heading="Video prompts"
|
||||||
/>
|
/>
|
||||||
@@ -1417,18 +1418,15 @@
|
|||||||
{{ lastFrameLoading ? 'Reading last frame…' : 'Last frame preview unavailable' }}
|
{{ lastFrameLoading ? 'Reading last frame…' : 'Last frame preview unavailable' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="block text-sm">
|
<PromptPartsEditor
|
||||||
<span class="mb-1 block text-zinc-400">Extension prompt</span>
|
v-model:pre="extendPromptPre"
|
||||||
<PromptField
|
v-model:prompt="extendPrompt"
|
||||||
v-model="extendPrompt"
|
v-model:post="extendPromptPost"
|
||||||
:rows="4"
|
prompt-placeholder="Continue the motion from this frame…"
|
||||||
title="Extension prompt"
|
:prompt-rows="4"
|
||||||
placeholder="Continue the motion from this frame…"
|
|
||||||
:keep-items="keepPrompts.video"
|
:keep-items="keepPrompts.video"
|
||||||
keep-heading="Video prompts"
|
keep-heading="Video prompts"
|
||||||
textarea-class=""
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<DurationField
|
<DurationField
|
||||||
v-model="extendDuration"
|
v-model="extendDuration"
|
||||||
label="Extension duration"
|
label="Extension duration"
|
||||||
@@ -1448,7 +1446,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full rounded-2xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
class="w-full rounded-2xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
:disabled="!extendPrompt.trim()"
|
:disabled="!composePromptParts(extendPromptPre, extendPrompt, extendPromptPost)"
|
||||||
@click="generateExtension"
|
@click="generateExtension"
|
||||||
>
|
>
|
||||||
{{ watchingLiveVideo() || videoBusy ? 'Queue extension' : 'Generate Extension' }}
|
{{ watchingLiveVideo() || videoBusy ? 'Queue extension' : 'Generate Extension' }}
|
||||||
@@ -2364,7 +2362,7 @@ interface RetryDraft {
|
|||||||
fps?: number
|
fps?: number
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
|
extensions?: { prompt: string; promptPre?: string; promptPost?: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
|
||||||
workflow?: string
|
workflow?: string
|
||||||
globalLocks?: string
|
globalLocks?: string
|
||||||
permanenceRefs?: PermanenceRef[]
|
permanenceRefs?: PermanenceRef[]
|
||||||
@@ -2378,6 +2376,8 @@ interface RetryDraft {
|
|||||||
interface QueuedExtension {
|
interface QueuedExtension {
|
||||||
id: string
|
id: string
|
||||||
prompt: string
|
prompt: string
|
||||||
|
promptPre: string
|
||||||
|
promptPost: string
|
||||||
duration: number
|
duration: number
|
||||||
loraName?: string
|
loraName?: string
|
||||||
loraStack?: LoraStackItem[]
|
loraStack?: LoraStackItem[]
|
||||||
@@ -2602,6 +2602,8 @@ const currentClipId = ref('')
|
|||||||
const extendOpen = ref(false)
|
const extendOpen = ref(false)
|
||||||
const extendClipId = ref('')
|
const extendClipId = ref('')
|
||||||
const extendPrompt = ref('')
|
const extendPrompt = ref('')
|
||||||
|
const extendPromptPre = ref('')
|
||||||
|
const extendPromptPost = ref('')
|
||||||
const extendDuration = ref(5)
|
const extendDuration = ref(5)
|
||||||
const lastFrameUrl = ref('')
|
const lastFrameUrl = ref('')
|
||||||
const lastFrameLoading = ref(false)
|
const lastFrameLoading = ref(false)
|
||||||
@@ -2998,7 +3000,7 @@ const canExtend = computed(() => Boolean(
|
|||||||
const extendSaveName = computed(() => nextClipPartName(currentClip.value?.name || currentClip.value?.prompt || ''))
|
const extendSaveName = computed(() => nextClipPartName(currentClip.value?.name || currentClip.value?.prompt || ''))
|
||||||
const queueReady = computed(() => {
|
const queueReady = computed(() => {
|
||||||
if (shotScriptMode.value) return parsedShots.value.length > 0 && parsedShots.value.every(shot => shot.prompt.trim())
|
if (shotScriptMode.value) return parsedShots.value.length > 0 && parsedShots.value.every(shot => shot.prompt.trim())
|
||||||
return extensionQueue.value.every(item => item.prompt.trim())
|
return extensionQueue.value.every(item => composePromptParts(item.promptPre, item.prompt, item.promptPost))
|
||||||
})
|
})
|
||||||
const canUseGeneratedStill = computed(() => Boolean(
|
const canUseGeneratedStill = computed(() => Boolean(
|
||||||
editResultUrl.value && !editLockedSave.value && !editAwaitingReveal.value
|
editResultUrl.value && !editLockedSave.value && !editAwaitingReveal.value
|
||||||
@@ -3204,10 +3206,10 @@ const plannedChain = computed(() => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ label: 'Initial', prompt: prompt.value, duration: duration.value },
|
{ label: 'Initial', prompt: composePromptParts(promptPre.value, prompt.value, promptPost.value), duration: duration.value },
|
||||||
...extensionQueue.value.map((item, index) => ({
|
...extensionQueue.value.map((item, index) => ({
|
||||||
label: `Extension ${index + 1}`,
|
label: `Extension ${index + 1}`,
|
||||||
prompt: item.prompt,
|
prompt: composePromptParts(item.promptPre, item.prompt, item.promptPost),
|
||||||
duration: item.duration
|
duration: item.duration
|
||||||
}))
|
}))
|
||||||
]
|
]
|
||||||
@@ -4128,6 +4130,26 @@ function applyPromptRestore(saved: string, stored?: { prompt?: string; promptPre
|
|||||||
promptPost.value = parts.post
|
promptPost.value = parts.post
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queuedExtensionFrom(
|
||||||
|
item: { prompt?: string; promptPre?: string; promptPost?: string; duration?: number; loraName?: string; loraStack?: LoraStackItem[] },
|
||||||
|
duration: number,
|
||||||
|
loraStack: LoraStackItem[]
|
||||||
|
): QueuedExtension {
|
||||||
|
const parts = restorePromptParts(item.prompt || '', {
|
||||||
|
pre: item.promptPre,
|
||||||
|
prompt: item.prompt,
|
||||||
|
post: item.promptPost
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
prompt: parts.prompt,
|
||||||
|
promptPre: parts.pre,
|
||||||
|
promptPost: parts.post,
|
||||||
|
duration,
|
||||||
|
loraStack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function insertKeepExtend(item: KeepPrompt) {
|
function insertKeepExtend(item: KeepPrompt) {
|
||||||
extendPrompt.value = insertKeepSnippet(extendPrompt.value, item.text)
|
extendPrompt.value = insertKeepSnippet(extendPrompt.value, item.text)
|
||||||
}
|
}
|
||||||
@@ -5155,7 +5177,7 @@ 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; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
|
extensions?: { prompt: string; promptPre?: string; promptPost?: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
|
||||||
queueAutoRun?: boolean
|
queueAutoRun?: boolean
|
||||||
promptPre?: string
|
promptPre?: string
|
||||||
promptPost?: string
|
promptPost?: string
|
||||||
@@ -5174,10 +5196,14 @@ async function restoreStudioJob(id: string) {
|
|||||||
outputFocus.value = 'video'
|
outputFocus.value = 'video'
|
||||||
closeExtend()
|
closeExtend()
|
||||||
const extensions = payload.extensions || []
|
const extensions = payload.extensions || []
|
||||||
|
const queuedAsCards = extensions.some(item =>
|
||||||
|
Object.prototype.hasOwnProperty.call(item, 'promptPre')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(item, 'promptPost')
|
||||||
|
)
|
||||||
applyVideoWorkflow(payload.workflow)
|
applyVideoWorkflow(payload.workflow)
|
||||||
applyPromptRestore(extensions.length
|
applyPromptRestore(queuedAsCards || !extensions.length
|
||||||
? [payload.prompt.trim(), ...extensions.map((item, index) => `shot ${index + 2}\n${item.prompt.trim()}`)].join('\n\n')
|
? (payload.prompt || '')
|
||||||
: (payload.prompt || ''), payload)
|
: [payload.prompt.trim(), ...extensions.map((item, index) => `shot ${index + 2}\n${item.prompt.trim()}`)].join('\n\n'), payload)
|
||||||
clipName.value = payload.name || ''
|
clipName.value = payload.name || ''
|
||||||
seedInput.value = String(payload.seed || '')
|
seedInput.value = String(payload.seed || '')
|
||||||
restoreAdvanced({
|
restoreAdvanced({
|
||||||
@@ -5198,14 +5224,13 @@ async function restoreStudioJob(id: string) {
|
|||||||
browseFolderId.value = payload.folderId
|
browseFolderId.value = payload.folderId
|
||||||
}
|
}
|
||||||
if (typeof payload.sound === 'boolean') withSound.value = payload.sound
|
if (typeof payload.sound === 'boolean') withSound.value = payload.sound
|
||||||
shotScriptMode.value = extensions.length > 0
|
shotScriptMode.value = extensions.length > 0 && !queuedAsCards
|
||||||
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
||||||
extensionQueue.value = extensions.map(item => ({
|
extensionQueue.value = extensions.map(item => queuedExtensionFrom(
|
||||||
id: crypto.randomUUID(),
|
item,
|
||||||
prompt: item.prompt,
|
clampDuration(Number(item.duration)),
|
||||||
duration: clampDuration(Number(item.duration)),
|
readLoraStack(item)
|
||||||
loraStack: readLoraStack(item)
|
))
|
||||||
}))
|
|
||||||
queueAutoRun.value = payload.queueAutoRun === true
|
queueAutoRun.value = payload.queueAutoRun === true
|
||||||
globalLocks.value = payload.globalLocks || ''
|
globalLocks.value = payload.globalLocks || ''
|
||||||
familyPermanenceRefs.value = payload.permanenceRefs || []
|
familyPermanenceRefs.value = payload.permanenceRefs || []
|
||||||
@@ -5590,12 +5615,11 @@ async function rerun(item: LibraryClip, collection = false) {
|
|||||||
restoreAll ? ordered : undefined
|
restoreAll ? ordered : undefined
|
||||||
)
|
)
|
||||||
extensionQueue.value = restoreAll
|
extensionQueue.value = restoreAll
|
||||||
? parts.slice(1).map(part => ({
|
? parts.slice(1).map(part => queuedExtensionFrom(
|
||||||
id: crypto.randomUUID(),
|
part,
|
||||||
prompt: part.prompt,
|
clipGenerateDuration(part),
|
||||||
duration: clipGenerateDuration(part),
|
loraStacksEqual(readLoraStack(part), readLoraStack(ordered[0] || target)) ? [] : readLoraStack(part)
|
||||||
loraStack: loraStacksEqual(readLoraStack(part), readLoraStack(ordered[0] || target)) ? [] : readLoraStack(part)
|
))
|
||||||
}))
|
|
||||||
: []
|
: []
|
||||||
try {
|
try {
|
||||||
const initialTextToVideo = textToVideo.value && !target.parentClipId && !(target.chainIndex)
|
const initialTextToVideo = textToVideo.value && !target.parentClipId && !(target.chainIndex)
|
||||||
@@ -5636,12 +5660,11 @@ async function loadDraft(draft: RetryDraft) {
|
|||||||
})
|
})
|
||||||
shotPermanenceRefs.value = draftShots
|
shotPermanenceRefs.value = draftShots
|
||||||
applyLoraSelection(draft, draft.shotLoraStacks || draft.shotLoras)
|
applyLoraSelection(draft, draft.shotLoraStacks || draft.shotLoras)
|
||||||
extensionQueue.value = (draft.extensions || []).map(item => ({
|
extensionQueue.value = (draft.extensions || []).map(item => queuedExtensionFrom(
|
||||||
id: crypto.randomUUID(),
|
item,
|
||||||
prompt: String(item.prompt || ''),
|
clampDuration(Number(item.duration)),
|
||||||
duration: clampDuration(Number(item.duration)),
|
readLoraStack(item)
|
||||||
loraStack: readLoraStack(item)
|
))
|
||||||
}))
|
|
||||||
try {
|
try {
|
||||||
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
|
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
|
||||||
const filename = draft.stillFilename || 'held-still.png'
|
const filename = draft.stillFilename || 'held-still.png'
|
||||||
@@ -5715,6 +5738,9 @@ function closeExtend() {
|
|||||||
lastFrameGen += 1
|
lastFrameGen += 1
|
||||||
extendOpen.value = false
|
extendOpen.value = false
|
||||||
extendClipId.value = ''
|
extendClipId.value = ''
|
||||||
|
extendPromptPre.value = ''
|
||||||
|
extendPrompt.value = ''
|
||||||
|
extendPromptPost.value = ''
|
||||||
lastFrameLoading.value = false
|
lastFrameLoading.value = false
|
||||||
lastFrameRevealed.value = false
|
lastFrameRevealed.value = false
|
||||||
if (lastFrameObjectUrl) {
|
if (lastFrameObjectUrl) {
|
||||||
@@ -5760,8 +5786,14 @@ async function openExtend(item?: LibraryClip) {
|
|||||||
currentClipId.value = id
|
currentClipId.value = id
|
||||||
extendClipId.value = id
|
extendClipId.value = id
|
||||||
const clip = clips.value.find(entry => entry.id === id)
|
const clip = clips.value.find(entry => entry.id === id)
|
||||||
if (clip) applyPromptRestore(clip.prompt, clip)
|
const parts = restorePromptParts(clip?.prompt || prompt.value, {
|
||||||
extendPrompt.value = clip?.prompt || prompt.value
|
pre: clip ? (clip.promptPre || '') : promptPre.value,
|
||||||
|
prompt: clip?.prompt || prompt.value,
|
||||||
|
post: clip ? (clip.promptPost || '') : promptPost.value
|
||||||
|
})
|
||||||
|
extendPromptPre.value = parts.pre
|
||||||
|
extendPrompt.value = parts.prompt
|
||||||
|
extendPromptPost.value = parts.post
|
||||||
extendDuration.value = clampDuration(duration.value)
|
extendDuration.value = clampDuration(duration.value)
|
||||||
extendLoraStack.value = readLoraStack(clip)
|
extendLoraStack.value = readLoraStack(clip)
|
||||||
lastFrameRevealed.value = false
|
lastFrameRevealed.value = false
|
||||||
@@ -5776,7 +5808,7 @@ async function openExtend(item?: LibraryClip) {
|
|||||||
|
|
||||||
async function generateExtension() {
|
async function generateExtension() {
|
||||||
const id = extendClipId.value || clipIdFromPlayer()
|
const id = extendClipId.value || clipIdFromPlayer()
|
||||||
if (!extendPrompt.value.trim()) return
|
if (!composePromptParts(extendPromptPre.value, extendPrompt.value, extendPromptPost.value)) return
|
||||||
if (!id) {
|
if (!id) {
|
||||||
toast('Load a generated clip before extending it.')
|
toast('Load a generated clip before extending it.')
|
||||||
return
|
return
|
||||||
@@ -5799,8 +5831,8 @@ async function generateExtension() {
|
|||||||
body: {
|
body: {
|
||||||
clipId: id,
|
clipId: id,
|
||||||
prompt: extendPrompt.value.trim(),
|
prompt: extendPrompt.value.trim(),
|
||||||
promptPre: promptPre.value.trim() || undefined,
|
promptPre: extendPromptPre.value,
|
||||||
promptPost: promptPost.value.trim() || undefined,
|
promptPost: extendPromptPost.value,
|
||||||
duration: extendDuration.value,
|
duration: extendDuration.value,
|
||||||
loraStack: extendLoraStack.value
|
loraStack: extendLoraStack.value
|
||||||
}
|
}
|
||||||
@@ -6475,6 +6507,8 @@ function queueExtension() {
|
|||||||
extensionQueue.value.push({
|
extensionQueue.value.push({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
prompt: prompt.value.trim(),
|
prompt: prompt.value.trim(),
|
||||||
|
promptPre: promptPre.value,
|
||||||
|
promptPost: promptPost.value,
|
||||||
duration: duration.value,
|
duration: duration.value,
|
||||||
loraStack: []
|
loraStack: []
|
||||||
})
|
})
|
||||||
@@ -7012,7 +7046,13 @@ async function generate() {
|
|||||||
const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim()
|
const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim()
|
||||||
const queued = shots
|
const queued = shots
|
||||||
? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value, ...persistLoraFields(shotLoraStacks.value[shot.n] || []) }))
|
? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value, ...persistLoraFields(shotLoraStacks.value[shot.n] || []) }))
|
||||||
: extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration, ...persistLoraFields(item.loraStack || []) }))
|
: extensionQueue.value.map(item => ({
|
||||||
|
prompt: item.prompt.trim(),
|
||||||
|
promptPre: item.promptPre,
|
||||||
|
promptPost: item.promptPost,
|
||||||
|
duration: item.duration,
|
||||||
|
...persistLoraFields(item.loraStack || [])
|
||||||
|
}))
|
||||||
const hideOut = hideThumbnail.value
|
const hideOut = hideThumbnail.value
|
||||||
const folderLockedPref = false
|
const folderLockedPref = false
|
||||||
const keepLiveOutput = watchingLiveVideo()
|
const keepLiveOutput = watchingLiveVideo()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { persistLoraFields, parsePostedLoraStack, listStudioLoras } from '~/serv
|
|||||||
import { readLoraStack } from '~/utils/loras'
|
import { readLoraStack } from '~/utils/loras'
|
||||||
import { parseExtendDuration } from '~/server/utils/extendChain'
|
import { parseExtendDuration } from '~/server/utils/extendChain'
|
||||||
import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
|
import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
|
||||||
|
import { composePromptParts } from '~/utils/promptParts'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody<{ clipId?: string; prompt?: string; promptPre?: string; promptPost?: string; duration?: number; lora?: unknown; loraStack?: unknown }>(event).catch(() => ({}))
|
const body = await readBody<{ clipId?: string; prompt?: string; promptPre?: string; promptPost?: string; duration?: number; lora?: unknown; loraStack?: unknown }>(event).catch(() => ({}))
|
||||||
@@ -12,9 +13,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!clipId) {
|
if (!clipId) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'A clip is required to extend' })
|
throw createError({ statusCode: 400, statusMessage: 'A clip is required to extend' })
|
||||||
}
|
}
|
||||||
if (!prompt) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'An extension prompt is required' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const source = getClip(owner, clipId)
|
const source = getClip(owner, clipId)
|
||||||
@@ -27,8 +25,15 @@ 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 promptPre = String(body?.promptPre ?? source.promptPre ?? '').trim()
|
const promptPre = body && Object.prototype.hasOwnProperty.call(body, 'promptPre')
|
||||||
const promptPost = String(body?.promptPost ?? source.promptPost ?? '').trim()
|
? String(body.promptPre || '').trim()
|
||||||
|
: String(source.promptPre || '').trim()
|
||||||
|
const promptPost = body && Object.prototype.hasOwnProperty.call(body, 'promptPost')
|
||||||
|
? String(body.promptPost || '').trim()
|
||||||
|
: String(source.promptPost || '').trim()
|
||||||
|
if (!composePromptParts(promptPre, prompt, promptPost)) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'An extension prompt is required' })
|
||||||
|
}
|
||||||
const workflow = parseVideoWorkflow(source.workflow)
|
const workflow = parseVideoWorkflow(source.workflow)
|
||||||
if (isLtxWorkflow(workflow) && !ltxWorkflowEnabled()) {
|
if (isLtxWorkflow(workflow) && !ltxWorkflowEnabled()) {
|
||||||
throw createError({ statusCode: 400, statusMessage: LTX_DISABLED_MESSAGE })
|
throw createError({ statusCode: 400, statusMessage: LTX_DISABLED_MESSAGE })
|
||||||
@@ -47,8 +52,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
payload: {
|
payload: {
|
||||||
prompt,
|
prompt,
|
||||||
promptMid: prompt,
|
promptMid: prompt,
|
||||||
promptPre: promptPre || undefined,
|
promptPre,
|
||||||
promptPost: promptPost || undefined,
|
promptPost,
|
||||||
name: nextClipPartName(clipTitle(source)),
|
name: nextClipPartName(clipTitle(source)),
|
||||||
folderId: source.folderId,
|
folderId: source.folderId,
|
||||||
aspect: source.aspect || '16:9',
|
aspect: source.aspect || '16:9',
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
|
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
|
||||||
import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras'
|
import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras'
|
||||||
import type { LoraStackItem } from '~/utils/loras'
|
|
||||||
import { clampVideoCfg } from '~/utils/generationPresets'
|
import { clampVideoCfg } from '~/utils/generationPresets'
|
||||||
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow, videoEngineOf } from '~/utils/videoModels'
|
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow, videoEngineOf } from '~/utils/videoModels'
|
||||||
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
|
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
|
||||||
import { composePromptParts } from '~/utils/promptParts'
|
import { composePromptParts, persistPromptWrappers } from '~/utils/promptParts'
|
||||||
|
import type { QueuedExtension } from '~/server/utils/library'
|
||||||
|
|
||||||
function parseDuration(raw: unknown) {
|
function parseDuration(raw: unknown) {
|
||||||
const seconds = Number(raw)
|
const seconds = Number(raw)
|
||||||
@@ -18,21 +18,26 @@ function parseExtendDuration(raw: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseExtensions(raw: string | undefined) {
|
function parseExtensions(raw: string | undefined) {
|
||||||
if (!raw) return [] as { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
|
if (!raw) return [] as QueuedExtension[]
|
||||||
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; permanenceRefs?: unknown; loraName?: unknown; loraStack?: unknown }) => {
|
.map((item: { prompt?: unknown; promptPre?: unknown; promptPost?: unknown; duration?: unknown; permanenceRefs?: unknown; loraName?: unknown; loraStack?: unknown }) => {
|
||||||
const loraStack = parsePostedLoraStack(item?.loraStack ?? item?.loraName, 'video')
|
const loraStack = parsePostedLoraStack(item?.loraStack ?? item?.loraName, 'video')
|
||||||
|
const wrappers = persistPromptWrappers({
|
||||||
|
...(item && Object.prototype.hasOwnProperty.call(item, 'promptPre') ? { promptPre: String(item.promptPre ?? '') } : {}),
|
||||||
|
...(item && Object.prototype.hasOwnProperty.call(item, 'promptPost') ? { promptPost: String(item.promptPost ?? '') } : {})
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
prompt: String(item?.prompt || '').trim(),
|
prompt: String(item?.prompt || '').trim(),
|
||||||
duration: parseExtendDuration(item?.duration),
|
duration: parseExtendDuration(item?.duration),
|
||||||
permanenceRefs: normalizePermanenceRefs(item?.permanenceRefs),
|
permanenceRefs: normalizePermanenceRefs(item?.permanenceRefs),
|
||||||
...persistLoraFields(loraStack)
|
...persistLoraFields(loraStack),
|
||||||
|
...wrappers
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(item => item.prompt)
|
.filter(item => composePromptParts(item.promptPre || '', item.prompt, item.promptPost || ''))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error
|
if (error && typeof error === 'object' && 'statusCode' in error) throw error
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -70,13 +70,16 @@ export async function beginExtendFromClip(params: {
|
|||||||
throw createError({ statusCode: 404, statusMessage: 'Source video is missing' })
|
throw createError({ statusCode: 404, statusMessage: 'Source video is missing' })
|
||||||
}
|
}
|
||||||
const prompt = String(params.prompt || '').trim()
|
const prompt = String(params.prompt || '').trim()
|
||||||
if (!prompt) {
|
const durationSeconds = parseExtendDuration(params.duration)
|
||||||
|
const promptPre = params.promptPre != null
|
||||||
|
? String(params.promptPre).trim()
|
||||||
|
: String(source.promptPre || '').trim()
|
||||||
|
const promptPost = params.promptPost != null
|
||||||
|
? String(params.promptPost).trim()
|
||||||
|
: String(source.promptPost || '').trim()
|
||||||
|
if (!composePromptParts(promptPre, prompt, promptPost)) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'An extension prompt is required' })
|
throw createError({ statusCode: 400, statusMessage: 'An extension prompt is required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationSeconds = parseExtendDuration(params.duration)
|
|
||||||
const promptPre = String(params.promptPre ?? source.promptPre ?? '').trim()
|
|
||||||
const promptPost = String(params.promptPost ?? source.promptPost ?? '').trim()
|
|
||||||
const composedPrompt = composeShotPrompt({
|
const composedPrompt = composeShotPrompt({
|
||||||
globalLocks: source.globalLocks,
|
globalLocks: source.globalLocks,
|
||||||
prompt: composePromptParts(promptPre, prompt, promptPost),
|
prompt: composePromptParts(promptPre, prompt, promptPost),
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export interface Job {
|
|||||||
duration?: number
|
duration?: number
|
||||||
sound?: boolean
|
sound?: boolean
|
||||||
draftId?: string
|
draftId?: string
|
||||||
extensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: import('~/utils/loras').LoraStackItem[] }[]
|
extensions?: import('~/server/utils/library').QueuedExtension[]
|
||||||
chainIndex?: number
|
chainIndex?: number
|
||||||
chainStep?: number
|
chainStep?: number
|
||||||
chainTotal?: number
|
chainTotal?: number
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export interface PublicFolder {
|
|||||||
|
|
||||||
export interface QueuedExtension {
|
export interface QueuedExtension {
|
||||||
prompt: string
|
prompt: string
|
||||||
|
promptPre?: string
|
||||||
|
promptPost?: string
|
||||||
duration: number
|
duration: number
|
||||||
permanenceRefs?: PermanenceRef[]
|
permanenceRefs?: PermanenceRef[]
|
||||||
loraName?: string
|
loraName?: string
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export interface PendingJob {
|
|||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
extensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: import('~/utils/loras').LoraStackItem[] }[]
|
extensions?: import('~/server/utils/library').QueuedExtension[]
|
||||||
remainingExtensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: import('~/utils/loras').LoraStackItem[] }[]
|
remainingExtensions?: import('~/server/utils/library').QueuedExtension[]
|
||||||
currentClipId?: string
|
currentClipId?: string
|
||||||
queueId?: string
|
queueId?: string
|
||||||
queueAutoRun?: boolean
|
queueAutoRun?: boolean
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { join } from 'node:path'
|
|||||||
import { getJob, listJobs, type Job } from '~/server/utils/jobs'
|
import { getJob, listJobs, type Job } from '~/server/utils/jobs'
|
||||||
import { parsePostedLoraStack, persistLoraFields } from '~/server/utils/loras'
|
import { parsePostedLoraStack, persistLoraFields } from '~/server/utils/loras'
|
||||||
import type { LoraStackItem } from '~/utils/loras'
|
import type { LoraStackItem } from '~/utils/loras'
|
||||||
|
import { persistPromptWrappers } from '~/utils/promptParts'
|
||||||
|
import type { QueuedExtension } from '~/server/utils/library'
|
||||||
|
|
||||||
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'
|
||||||
@@ -10,6 +12,8 @@ export type ShotSegmentStatus = 'pending' | 'running' | 'complete' | 'error'
|
|||||||
export interface ShotQueueSegment {
|
export interface ShotQueueSegment {
|
||||||
index: number
|
index: number
|
||||||
prompt: string
|
prompt: string
|
||||||
|
promptPre?: string
|
||||||
|
promptPost?: string
|
||||||
duration: number
|
duration: number
|
||||||
status: ShotSegmentStatus
|
status: ShotSegmentStatus
|
||||||
clipId?: string
|
clipId?: string
|
||||||
@@ -187,7 +191,7 @@ export async function createShotQueue(params: {
|
|||||||
loraName?: string
|
loraName?: string
|
||||||
loraStack?: LoraStackItem[]
|
loraStack?: LoraStackItem[]
|
||||||
initial: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }
|
initial: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }
|
||||||
extensions: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
|
extensions: QueuedExtension[]
|
||||||
jobId?: string
|
jobId?: string
|
||||||
}): Promise<ShotQueue> {
|
}): Promise<ShotQueue> {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -231,7 +235,8 @@ export async function createShotQueue(params: {
|
|||||||
duration: item.duration,
|
duration: item.duration,
|
||||||
status: 'pending' as const,
|
status: 'pending' as const,
|
||||||
permanenceRefs: item.permanenceRefs,
|
permanenceRefs: item.permanenceRefs,
|
||||||
...persistLoraFields(item.loraStack || item.loraName)
|
...persistLoraFields(item.loraStack || item.loraName),
|
||||||
|
...persistPromptWrappers(item)
|
||||||
}))
|
}))
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -473,10 +478,16 @@ export function listOwnersWithQueues() {
|
|||||||
.filter(owner => existsSync(queuesPath(owner)))
|
.filter(owner => existsSync(queuesPath(owner)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function remainingFromQueue(queue: ShotQueue): { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[] {
|
export function remainingFromQueue(queue: ShotQueue): QueuedExtension[] {
|
||||||
return queue.segments
|
return queue.segments
|
||||||
.filter(segment => segment.index > 0)
|
.filter(segment => segment.index > 0)
|
||||||
.map(segment => ({ prompt: segment.prompt, duration: segment.duration, permanenceRefs: segment.permanenceRefs, ...persistLoraFields(segment.loraStack || segment.loraName) }))
|
.map(segment => ({
|
||||||
|
prompt: segment.prompt,
|
||||||
|
duration: segment.duration,
|
||||||
|
permanenceRefs: segment.permanenceRefs,
|
||||||
|
...persistLoraFields(segment.loraStack || segment.loraName),
|
||||||
|
...persistPromptWrappers(segment)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lastCompletedIndex(queue: ShotQueue) {
|
export function lastCompletedIndex(queue: ShotQueue) {
|
||||||
@@ -491,6 +502,7 @@ export function liveSegmentPrompt(queue: ShotQueue, extensionIndex: number) {
|
|||||||
prompt: segment?.prompt || '',
|
prompt: segment?.prompt || '',
|
||||||
duration: segment?.duration || 5,
|
duration: segment?.duration || 5,
|
||||||
permanenceRefs: segment?.permanenceRefs,
|
permanenceRefs: segment?.permanenceRefs,
|
||||||
...persistLoraFields(segment?.loraStack || segment?.loraName)
|
...persistLoraFields(segment?.loraStack || segment?.loraName),
|
||||||
|
...persistPromptWrappers(segment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { fetchLiveQueue } from '~/server/utils/comfy'
|
|||||||
import { isLtxWorkflow, isTextToVideo, isXaigenStudio, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
|
import { isLtxWorkflow, isTextToVideo, isXaigenStudio, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
|
||||||
import { persistLoraFields } from '~/server/utils/loras'
|
import { persistLoraFields } from '~/server/utils/loras'
|
||||||
import { resolveLoraStack } from '~/utils/loras'
|
import { resolveLoraStack } from '~/utils/loras'
|
||||||
import { allowIdentityRefs } from '~/utils/globalLocks'
|
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
||||||
export type StudioJobKind = 'video' | 'edit'
|
export type StudioJobKind = 'video' | 'edit'
|
||||||
@@ -39,7 +39,7 @@ export interface StudioJobPayload {
|
|||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
referenceStillIds: Array<string | null>
|
referenceStillIds: Array<string | null>
|
||||||
extensions: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: import('~/utils/loras').LoraStackItem[] }[]
|
extensions: import('~/server/utils/library').QueuedExtension[]
|
||||||
queueAutoRun: boolean
|
queueAutoRun: boolean
|
||||||
globalLocks?: string
|
globalLocks?: string
|
||||||
permanenceRefs?: PermanenceRef[]
|
permanenceRefs?: PermanenceRef[]
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import {
|
|||||||
updateShotQueue
|
updateShotQueue
|
||||||
} from '~/server/utils/shotQueue'
|
} from '~/server/utils/shotQueue'
|
||||||
import { composeShotPrompt, allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
import { composeShotPrompt, allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||||
import { composePromptParts } from '~/utils/promptParts'
|
import { composePromptParts, resolvePromptWrappers } from '~/utils/promptParts'
|
||||||
|
import type { QueuedExtension } from '~/server/utils/library'
|
||||||
import { persistLoraFields, ensureComfyLoraNames } from '~/server/utils/loras'
|
import { persistLoraFields, ensureComfyLoraNames } from '~/server/utils/loras'
|
||||||
import { readLoraStack, resolveLoraStack } from '~/utils/loras'
|
import { readLoraStack, resolveLoraStack } from '~/utils/loras'
|
||||||
import type { LoraStackItem } from '~/utils/loras'
|
import type { LoraStackItem } from '~/utils/loras'
|
||||||
@@ -43,7 +44,7 @@ type VideoChainParams = {
|
|||||||
fps: number
|
fps: number
|
||||||
samplerName: string
|
samplerName: string
|
||||||
scheduler: string
|
scheduler: string
|
||||||
extensions: { prompt: string; duration: number; loraName?: string; loraStack?: LoraStackItem[]; permanenceRefs?: PermanenceRef[] }[]
|
extensions: QueuedExtension[]
|
||||||
workflow: VideoWorkflowId
|
workflow: VideoWorkflowId
|
||||||
duration: number
|
duration: number
|
||||||
useIdentityRefs: boolean
|
useIdentityRefs: boolean
|
||||||
@@ -323,11 +324,12 @@ export async function continueQueuedExtensions(
|
|||||||
prompt: live.prompt || extensions[i].prompt,
|
prompt: live.prompt || extensions[i].prompt,
|
||||||
duration: live.duration || extensions[i].duration
|
duration: live.duration || extensions[i].duration
|
||||||
}
|
}
|
||||||
|
const wrappers = resolvePromptWrappers(live, extensions[i], job.library)
|
||||||
const shotStack = resolveLoraStack(
|
const shotStack = resolveLoraStack(
|
||||||
readLoraStack(liveQueue).length ? readLoraStack(liveQueue) : (params.loraStack || params.loraName),
|
readLoraStack(liveQueue).length ? readLoraStack(liveQueue) : (params.loraStack || params.loraName),
|
||||||
live.loraStack || live.loraName || extensions[i]?.loraStack || extensions[i]?.loraName
|
live.loraStack || live.loraName || extensions[i]?.loraStack || extensions[i]?.loraName
|
||||||
)
|
)
|
||||||
if (!ext.prompt.trim()) {
|
if (!composePromptParts(wrappers.promptPre, ext.prompt, wrappers.promptPost)) {
|
||||||
throw new Error(`Shot ${i + 2} needs a prompt`)
|
throw new Error(`Shot ${i + 2} needs a prompt`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +387,10 @@ export async function continueQueuedExtensions(
|
|||||||
throw new Error('Could not extract the last frame for the next extension: the frame file was empty')
|
throw new Error('Could not extract the last frame for the next extension: the frame file was empty')
|
||||||
}
|
}
|
||||||
job.library.extendPart1Path = part1Path
|
job.library.extendPart1Path = part1Path
|
||||||
job.library.prompt = ext.prompt
|
job.library.prompt = composePromptParts(wrappers.promptPre, ext.prompt, wrappers.promptPost)
|
||||||
|
job.library.promptMid = ext.prompt
|
||||||
|
job.library.promptPre = wrappers.promptPre || undefined
|
||||||
|
job.library.promptPost = wrappers.promptPost || undefined
|
||||||
if (live.permanenceRefs?.length) {
|
if (live.permanenceRefs?.length) {
|
||||||
const refs = [...(job.library.shotPermanenceRefs || [])]
|
const refs = [...(job.library.shotPermanenceRefs || [])]
|
||||||
refs[i + 1] = live.permanenceRefs
|
refs[i + 1] = live.permanenceRefs
|
||||||
|
|||||||
@@ -36,6 +36,46 @@ export function restorePromptParts(saved: string, stored?: Partial<PromptParts>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PromptWrapperFields = {
|
||||||
|
promptPre?: string
|
||||||
|
promptPost?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWrapperKey(item: object | null | undefined, key: 'promptPre' | 'promptPost') {
|
||||||
|
return Boolean(item && Object.prototype.hasOwnProperty.call(item, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First source that actually sent the key wins, including empty string. Omitted keys fall through. */
|
||||||
|
export function resolvePromptWrappers(
|
||||||
|
...sources: Array<PromptWrapperFields | null | undefined>
|
||||||
|
) {
|
||||||
|
let promptPre = ''
|
||||||
|
let promptPost = ''
|
||||||
|
let sawPre = false
|
||||||
|
let sawPost = false
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) continue
|
||||||
|
if (!sawPre && hasWrapperKey(source, 'promptPre') && source.promptPre != null) {
|
||||||
|
promptPre = sanitizePromptPart(source.promptPre)
|
||||||
|
sawPre = true
|
||||||
|
}
|
||||||
|
if (!sawPost && hasWrapperKey(source, 'promptPost') && source.promptPost != null) {
|
||||||
|
promptPost = sanitizePromptPart(source.promptPost)
|
||||||
|
sawPost = true
|
||||||
|
}
|
||||||
|
if (sawPre && sawPost) break
|
||||||
|
}
|
||||||
|
return { promptPre, promptPost }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistPromptWrappers(item?: PromptWrapperFields | null): PromptWrapperFields {
|
||||||
|
if (!item) return {}
|
||||||
|
const next: PromptWrapperFields = {}
|
||||||
|
if (hasWrapperKey(item, 'promptPre') && item.promptPre != null) next.promptPre = sanitizePromptPart(item.promptPre)
|
||||||
|
if (hasWrapperKey(item, 'promptPost') && item.promptPost != null) next.promptPost = sanitizePromptPart(item.promptPost)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
export function formatPromptParts(
|
export function formatPromptParts(
|
||||||
stored?: { prompt?: string; promptPre?: string; promptPost?: string } | null,
|
stored?: { prompt?: string; promptPre?: string; promptPost?: string } | null,
|
||||||
midOverride?: string
|
midOverride?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user