Split prompts into pre/prompt/post and tidy the AIGen video form.
Keep wrappers off the library mid-prompt so Use as input and Extend restore the same split, send stills to xAIGen as hidden inputs, and stop the two-column library from growing with the list. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,6 +24,11 @@ NUXT_OIDC_AUTHORIZE_URL=https://auth.carrgarage.com/application/o/authorize/
|
||||
NUXT_OIDC_TOKEN_URL=http://192.168.77.2:9000/application/o/token/
|
||||
LIBRARY_DIR=/data/library
|
||||
|
||||
# AIGen → xAIGen still backup. Set the URL + token on AIGen. Set the same token on xAIGen as BACKUP_INGEST_TOKEN.
|
||||
# BACKUP_STUDIO_URL=https://xaigen.carrgarage.com
|
||||
# BACKUP_STUDIO_TOKEN=
|
||||
# BACKUP_INGEST_TOKEN=
|
||||
|
||||
# Private clone (xaigen): password login, isolated library, Comfy prefix
|
||||
# AUTH_MODE=password
|
||||
# NUXT_PUBLIC_INSTANCE_NAME=xAIGen
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
|
||||
<details v-if="!embedded" 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>
|
||||
@@ -7,31 +7,45 @@
|
||||
<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"
|
||||
<div class="mt-3">
|
||||
<PromptField
|
||||
:model-value="modelValue"
|
||||
:rows="10"
|
||||
title="Global locks"
|
||||
write-label="Write locks"
|
||||
edit-label="View / edit locks"
|
||||
textarea-class="max-h-64"
|
||||
:placeholder="placeholder"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</details>
|
||||
<div v-else>
|
||||
<p class="text-sm font-medium text-zinc-200">Global locks</p>
|
||||
<p class="mt-0.5 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>
|
||||
<div class="mt-3">
|
||||
<PromptField
|
||||
:model-value="modelValue"
|
||||
:rows="10"
|
||||
title="Global locks"
|
||||
write-label="Write locks"
|
||||
edit-label="View / edit locks"
|
||||
textarea-class="max-h-64"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -39,6 +53,7 @@ import { DEFAULT_GLOBAL_LOCKS } from '~/utils/globalLocks'
|
||||
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<textarea
|
||||
:value="modelValue"
|
||||
:rows="rows"
|
||||
class="hidden 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 md:block"
|
||||
:class="textareaClass"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onInput"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-left md:hidden"
|
||||
:disabled="disabled"
|
||||
@click="drawerOpen = true"
|
||||
>
|
||||
<p v-if="modelValue.trim()" class="line-clamp-4 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ modelValue }}</p>
|
||||
<p v-else class="text-sm text-zinc-600">{{ placeholder }}</p>
|
||||
<span class="mt-1 inline-block text-xs text-amber-200">{{ modelValue.trim() ? editLabel : writeLabel }}</span>
|
||||
</button>
|
||||
<PromptDrawer
|
||||
:open="drawerOpen"
|
||||
:title="title"
|
||||
:text="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:keep-items="keepItems"
|
||||
:keep-heading="keepHeading"
|
||||
@update:open="drawerOpen = $event"
|
||||
@update:text="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { KeepPrompt } from '~/utils/keepPrompts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
rows?: number
|
||||
placeholder?: string
|
||||
title?: string
|
||||
editLabel?: string
|
||||
writeLabel?: string
|
||||
textareaClass?: string
|
||||
disabled?: boolean
|
||||
keepItems?: KeepPrompt[]
|
||||
keepHeading?: string
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
rows: 3,
|
||||
placeholder: '',
|
||||
title: 'Prompt',
|
||||
editLabel: 'View / edit',
|
||||
writeLabel: 'Write',
|
||||
textareaClass: 'max-h-32 md:max-h-none',
|
||||
disabled: false,
|
||||
keepItems: () => [],
|
||||
keepHeading: 'Saved prompts'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
function onInput(event: Event) {
|
||||
emit('update:modelValue', (event.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<KeepPromptMenu
|
||||
:items="keepItems"
|
||||
:heading="keepHeading"
|
||||
:disabled="disabled"
|
||||
@insert="insertSnippet"
|
||||
/>
|
||||
<div class="inline-flex rounded-full border border-white/10 p-0.5">
|
||||
<button
|
||||
v-for="option in targets"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="rounded-full px-2.5 py-1 text-[11px]"
|
||||
:class="snippetTarget === option.id ? 'bg-amber-400/15 text-amber-100' : 'text-zinc-500 hover:text-zinc-300'"
|
||||
:disabled="disabled"
|
||||
@click="snippetTarget = option.id"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-[11px] text-zinc-500">Snippets go into {{ targetLabel }}.</span>
|
||||
</div>
|
||||
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block text-xs font-medium text-zinc-400">Pre-text</span>
|
||||
<textarea
|
||||
:value="pre"
|
||||
rows="2"
|
||||
class="hidden max-h-40 w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-2.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:block"
|
||||
:placeholder="prePlaceholder"
|
||||
:disabled="disabled"
|
||||
@focus="snippetTarget = 'pre'"
|
||||
@input="onInput('pre', $event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-2.5 text-left md:hidden"
|
||||
@click="openDrawer('pre')"
|
||||
>
|
||||
<p v-if="pre.trim()" class="line-clamp-3 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ pre }}</p>
|
||||
<p v-else class="text-sm text-zinc-600">{{ prePlaceholder }}</p>
|
||||
<span class="mt-1 inline-block text-xs text-amber-200">{{ pre.trim() ? 'View / edit pre-text' : 'Write pre-text' }}</span>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block text-xs font-medium text-zinc-400">Prompt</span>
|
||||
<textarea
|
||||
:value="prompt"
|
||||
:rows="promptRows"
|
||||
class="hidden max-h-[min(28rem,70vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:block"
|
||||
:placeholder="promptPlaceholder"
|
||||
:disabled="disabled"
|
||||
@focus="snippetTarget = 'prompt'"
|
||||
@input="onInput('prompt', $event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-left md:hidden"
|
||||
@click="openDrawer('prompt')"
|
||||
>
|
||||
<p v-if="prompt.trim()" class="line-clamp-4 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ prompt }}</p>
|
||||
<p v-else class="text-sm text-zinc-600">{{ promptPlaceholder }}</p>
|
||||
<span class="mt-2 inline-block text-xs text-amber-200">{{ prompt.trim() ? 'View / edit prompt' : 'Write prompt' }}</span>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block text-xs font-medium text-zinc-400">Post-text</span>
|
||||
<textarea
|
||||
:value="post"
|
||||
rows="2"
|
||||
class="hidden max-h-40 w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-2.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:block"
|
||||
:placeholder="postPlaceholder"
|
||||
:disabled="disabled"
|
||||
@focus="snippetTarget = 'post'"
|
||||
@input="onInput('post', $event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-2.5 text-left md:hidden"
|
||||
@click="openDrawer('post')"
|
||||
>
|
||||
<p v-if="post.trim()" class="line-clamp-3 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ post }}</p>
|
||||
<p v-else class="text-sm text-zinc-600">{{ postPlaceholder }}</p>
|
||||
<span class="mt-1 inline-block text-xs text-amber-200">{{ post.trim() ? 'View / edit post-text' : 'Write post-text' }}</span>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div v-if="keepItems.length" class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="item in keepItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="max-w-full truncate rounded-full border border-white/10 px-2.5 py-1 text-xs text-zinc-300 hover:border-amber-300/40 hover:text-amber-100"
|
||||
:title="item.text"
|
||||
:disabled="disabled"
|
||||
@click="insertSnippet(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PromptDrawer
|
||||
:open="drawerOpen"
|
||||
:title="drawerTitle"
|
||||
:text="drawerText"
|
||||
:placeholder="drawerPlaceholder"
|
||||
:keep-items="keepItems"
|
||||
:keep-heading="keepHeading"
|
||||
@update:open="drawerOpen = $event"
|
||||
@update:text="onDrawerText"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { insertKeepSnippet, type KeepPrompt } from '~/utils/keepPrompts'
|
||||
|
||||
const targets = [
|
||||
{ id: 'pre' as const, label: 'Pre' },
|
||||
{ id: 'prompt' as const, label: 'Prompt' },
|
||||
{ id: 'post' as const, label: 'Post' }
|
||||
]
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
pre?: string
|
||||
prompt?: string
|
||||
post?: string
|
||||
keepItems?: KeepPrompt[]
|
||||
keepHeading?: string
|
||||
promptPlaceholder?: string
|
||||
prePlaceholder?: string
|
||||
postPlaceholder?: string
|
||||
promptRows?: number
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
pre: '',
|
||||
prompt: '',
|
||||
post: '',
|
||||
keepItems: () => [],
|
||||
keepHeading: 'Saved prompts',
|
||||
promptPlaceholder: '',
|
||||
prePlaceholder: 'Optional text before the prompt…',
|
||||
postPlaceholder: 'Optional text after the prompt…',
|
||||
promptRows: 6,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:pre': [value: string]
|
||||
'update:prompt': [value: string]
|
||||
'update:post': [value: string]
|
||||
}>()
|
||||
|
||||
const snippetTarget = ref<'pre' | 'prompt' | 'post'>('prompt')
|
||||
const drawerField = ref<'pre' | 'prompt' | 'post'>('prompt')
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
const targetLabel = computed(() => targets.find(item => item.id === snippetTarget.value)?.label.toLowerCase() || 'prompt')
|
||||
const drawerTitle = computed(() => {
|
||||
if (drawerField.value === 'pre') return 'Pre-text'
|
||||
if (drawerField.value === 'post') return 'Post-text'
|
||||
return 'Prompt'
|
||||
})
|
||||
const drawerPlaceholder = computed(() => {
|
||||
if (drawerField.value === 'pre') return props.prePlaceholder
|
||||
if (drawerField.value === 'post') return props.postPlaceholder
|
||||
return props.promptPlaceholder
|
||||
})
|
||||
const drawerText = computed(() => {
|
||||
if (drawerField.value === 'pre') return props.pre
|
||||
if (drawerField.value === 'post') return props.post
|
||||
return props.prompt
|
||||
})
|
||||
|
||||
function currentValue(target: 'pre' | 'prompt' | 'post') {
|
||||
if (target === 'pre') return props.pre
|
||||
if (target === 'post') return props.post
|
||||
return props.prompt
|
||||
}
|
||||
|
||||
function emitField(target: 'pre' | 'prompt' | 'post', value: string) {
|
||||
if (target === 'pre') emit('update:pre', value)
|
||||
else if (target === 'post') emit('update:post', value)
|
||||
else emit('update:prompt', value)
|
||||
}
|
||||
|
||||
function onInput(target: 'pre' | 'prompt' | 'post', event: Event) {
|
||||
emitField(target, (event.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
|
||||
function insertSnippet(item: KeepPrompt) {
|
||||
emitField(snippetTarget.value, insertKeepSnippet(currentValue(snippetTarget.value), item.text))
|
||||
}
|
||||
|
||||
function openDrawer(target: 'pre' | 'prompt' | 'post') {
|
||||
snippetTarget.value = target
|
||||
drawerField.value = target
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function onDrawerText(value: string) {
|
||||
emitField(drawerField.value, value)
|
||||
}
|
||||
</script>
|
||||
+5
-1
@@ -52,6 +52,9 @@ export default defineNuxtConfig({
|
||||
authMode: process.env.NUXT_AUTH_MODE || process.env.AUTH_MODE || '',
|
||||
authUsername: process.env.AUTH_USERNAME || '',
|
||||
authPassword: process.env.AUTH_PASSWORD || '',
|
||||
backupStudioUrl: process.env.BACKUP_STUDIO_URL || '',
|
||||
backupStudioToken: process.env.BACKUP_STUDIO_TOKEN || '',
|
||||
backupIngestToken: process.env.BACKUP_INGEST_TOKEN || '',
|
||||
purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false',
|
||||
comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3',
|
||||
kreaConceptLora: process.env.NUXT_KREA_CONCEPT_LORA || '',
|
||||
@@ -61,7 +64,8 @@ export default defineNuxtConfig({
|
||||
instanceName: process.env.NUXT_PUBLIC_INSTANCE_NAME || 'AIGen',
|
||||
// NUXT_PUBLIC_STUDIO=xaigen on the xAIGen Coolify app (LTX Pink Cherry + xAIGen-prefixed LoRAs)
|
||||
studio: process.env.NUXT_PUBLIC_STUDIO || '',
|
||||
kreaConceptLora: process.env.NUXT_PUBLIC_KREA_CONCEPT_LORA || process.env.NUXT_KREA_CONCEPT_LORA || ''
|
||||
kreaConceptLora: process.env.NUXT_PUBLIC_KREA_CONCEPT_LORA || process.env.NUXT_KREA_CONCEPT_LORA || '',
|
||||
backupStudioEnabled: Boolean(String(process.env.BACKUP_STUDIO_URL || '').trim()) && !xaigenStudio
|
||||
}
|
||||
},
|
||||
nitro: {
|
||||
|
||||
+162
-127
@@ -7,7 +7,7 @@
|
||||
@gpu="pollComfyHealth"
|
||||
/>
|
||||
|
||||
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<main class="mx-auto grid min-w-0 max-w-7xl items-start gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<section id="studio-input" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
|
||||
<div>
|
||||
<div class="mb-4 grid grid-cols-2 gap-3">
|
||||
@@ -219,12 +219,6 @@
|
||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<label class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' && v2Mode === 'generate' ? 'Generate prompt' : studioMode === 'editv2' && v2Mode === 'refine' ? 'Refine prompt' : studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<KeepPromptMenu
|
||||
:items="activeKeepPrompts"
|
||||
:heading="keepPromptHeading"
|
||||
align="right"
|
||||
@insert="insertKeepPrompt"
|
||||
/>
|
||||
<button
|
||||
v-if="identityPrompting"
|
||||
type="button"
|
||||
@@ -284,21 +278,15 @@
|
||||
:class="studioMode === 'edit' || studioMode === 'editv2' ? 'md:grid-cols-2' : ''"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<textarea
|
||||
v-model="prompt"
|
||||
:rows="shotScriptMode || identityPrompting ? 12 : 6"
|
||||
class="hidden max-h-[min(28rem,70vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:block"
|
||||
:placeholder="promptPlaceholder"
|
||||
<PromptPartsEditor
|
||||
v-model:pre="promptPre"
|
||||
v-model:prompt="prompt"
|
||||
v-model:post="promptPost"
|
||||
:prompt-placeholder="promptPlaceholder"
|
||||
:prompt-rows="shotScriptMode || identityPrompting ? 12 : 6"
|
||||
:keep-items="activeKeepPrompts"
|
||||
:keep-heading="keepPromptHeading"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-left md:hidden"
|
||||
@click="promptDrawerOpen = true"
|
||||
>
|
||||
<p v-if="prompt.trim()" class="line-clamp-4 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ prompt }}</p>
|
||||
<p v-else class="text-sm text-zinc-600">{{ promptPlaceholder }}</p>
|
||||
<span class="mt-2 inline-block text-xs text-amber-200">{{ prompt.trim() ? 'View / edit prompt' : 'Write prompt' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<label v-if="studioMode === 'edit' || studioMode === 'editv2'" class="block min-w-0 text-sm">
|
||||
<span class="mb-2 block font-medium text-zinc-300">Negative prompt</span>
|
||||
@@ -318,18 +306,6 @@
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="activeKeepPrompts.length" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="item in activeKeepPrompts"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="max-w-full truncate rounded-full border border-white/10 px-2.5 py-1 text-xs text-zinc-300 hover:border-amber-300/40 hover:text-amber-100"
|
||||
:title="item.text"
|
||||
@click="insertKeepPrompt(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="studioMode === 'editv2' && v2Mode === 'generate'" class="mt-3 text-xs text-zinc-500">No reference image. Prompt only.</p>
|
||||
<div v-if="studioMode === 'editv2' && v2Mode === 'refine'" class="mt-3 space-y-2">
|
||||
<p class="text-xs text-zinc-500">Prompt only what should change in the painted area.</p>
|
||||
@@ -432,11 +408,14 @@
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
<PromptField
|
||||
v-model="item.prompt"
|
||||
rows="3"
|
||||
class="w-full 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 focus:ring-2"
|
||||
:rows="3"
|
||||
:title="`${imagePassMode === 'batch' ? 'Prompt' : 'Pass'} ${index + 2}`"
|
||||
:placeholder="extraPassPlaceholder"
|
||||
:keep-items="activeKeepPrompts"
|
||||
:keep-heading="keepPromptHeading"
|
||||
:disabled="editBusy"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -458,15 +437,20 @@
|
||||
<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>
|
||||
</details>
|
||||
<div v-if="studioMode === 'video'" class="mt-3 space-y-3">
|
||||
<GlobalLocksEditor v-model="globalLocks" />
|
||||
<details v-if="studioMode === 'video'" class="mt-3 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">
|
||||
Continuity
|
||||
<span class="ml-2 font-normal text-xs text-zinc-500">Locks and named stills. Last-frame I2V carries the picture.</span>
|
||||
</summary>
|
||||
<div class="mt-3 space-y-3">
|
||||
<GlobalLocksEditor v-model="globalLocks" embedded />
|
||||
<div class="rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2.5">
|
||||
<PermanenceRefStrip
|
||||
:refs="familyPermanenceRefs"
|
||||
:name-options="lockBindingKeys"
|
||||
label="Permanence refs · all shots"
|
||||
label="Named stills · all shots"
|
||||
hint="Name + still"
|
||||
cta="Add permanence refs"
|
||||
cta="Add a named still"
|
||||
add-title="Choose a still to name for every shot"
|
||||
:hidden="hideInputPreview"
|
||||
@add-ref="openPermanencePicker()"
|
||||
@@ -474,12 +458,13 @@
|
||||
@update-label="updateFamilyRefLabel"
|
||||
/>
|
||||
<p class="mt-2 text-[11px] leading-relaxed text-zinc-500">
|
||||
MiniMax will not see these photos. Continuity is the last frame of the previous clip. The stills only name the lock lines.
|
||||
MiniMax does not see these photos. Shot 2+ starts from the previous clip’s last frame. Names only go in the lock line.
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] leading-relaxed text-zinc-500">{{ permanenceComfyNote }}</p>
|
||||
<p v-if="familyRefLabelLine" class="mt-1 text-[11px] text-amber-200/80">{{ familyRefLabelLine }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 space-y-2">
|
||||
<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
|
||||
@@ -532,16 +517,7 @@
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium text-zinc-200">Extension #{{ index + 1 }}</p>
|
||||
<KeepPromptMenu
|
||||
:items="keepPrompts.video"
|
||||
heading="Video prompts"
|
||||
align="left"
|
||||
:disabled="videoBusy"
|
||||
@insert="insertKeepIntoExtension(item.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-red-300 hover:text-red-200"
|
||||
@@ -551,11 +527,14 @@
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
<PromptField
|
||||
v-model="item.prompt"
|
||||
rows="3"
|
||||
class="max-h-32 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 md:max-h-none"
|
||||
:rows="3"
|
||||
:title="`Extension #${index + 1}`"
|
||||
placeholder="Motion prompt for this extension…"
|
||||
:keep-items="keepPrompts.video"
|
||||
keep-heading="Video prompts"
|
||||
:disabled="videoBusy"
|
||||
/>
|
||||
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
|
||||
<LoraSelect
|
||||
@@ -737,11 +716,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="videoEngine === 'minimax' && videoStart === 'still' && minimaxGraph === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 space-y-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-zinc-200">Character stills</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-500">Optional. Extra views of the same person — face, hair, body. Shot 2+ still starts from the last frame (clothes and pose stay). Empty slots are left out. A named extra person turns these off.</p>
|
||||
</div>
|
||||
<details v-if="videoEngine === 'minimax' && videoStart === 'still' && minimaxGraph === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3" :open="identityRefs.some(Boolean)">
|
||||
<summary class="cursor-pointer text-sm font-medium text-zinc-200">
|
||||
Character stills
|
||||
<span class="ml-2 font-normal text-xs text-zinc-500">Optional extra views of the same person. Off unless you need them.</span>
|
||||
</summary>
|
||||
<div class="mt-3 space-y-3">
|
||||
<p class="text-xs text-zinc-500">Face, hair, body. Shot 2+ still starts from the last frame (clothes and pose stay). Empty slots are left out. A named extra person turns these off.</p>
|
||||
<div v-if="!extraPersonLock" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div
|
||||
v-for="slot in 4"
|
||||
@@ -783,6 +764,7 @@
|
||||
<p v-if="extraPersonLock" class="text-xs text-zinc-500">Character stills stay off while a second named person is attached. Shot 2+ starts from the last frame.</p>
|
||||
<p v-else class="text-xs text-zinc-500">Picture 1 is the start still on shot 1 and the last frame after that. Extra stills are Picture 2–5. They do not put the start-still outfit back on.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div v-if="studioMode === 'video'" class="max-w-md space-y-2">
|
||||
@@ -1046,20 +1028,15 @@
|
||||
<p class="text-xs text-zinc-500">Graph default is 20 steps, CFG 1, euler. CFG 0–20 (0.1 steps), steps 1–50. A second still switches the two-image branch on. Scale to 1 MP runs Scale Image to Total Pixels (lanczos) on both stills before Klein.</p>
|
||||
</div>
|
||||
|
||||
<details v-if="studioMode === 'video'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
|
||||
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<AspectRatioPick v-model="aspect" heading="Aspect ratio / resolution" :options="videoAspects" />
|
||||
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? (file ? ' · matching the still' : ' · 16:9 until a still is loaded') : '' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = true">{{ ltxVideo ? '8 Distilled LoRA' : (videoWorkflow === 'v2' ? '6 Turbo · LoRA on' : '8 Turbo · LoRA on') }}</button>
|
||||
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="!turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = false">{{ ltxVideo ? '12 Quality · LoRA on' : '20 High-Fidelity · LoRA bypass' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="studioMode === 'video'" class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<DurationField
|
||||
v-model="duration"
|
||||
label="Duration"
|
||||
:hint="shotScriptMode
|
||||
? 'This length applies to the initial shot and every extension in the script.'
|
||||
: 'Type any length, or hover the wheel and scroll. Shift-scroll jumps by 1s. Drag up and down.'"
|
||||
/>
|
||||
<div class="text-sm">
|
||||
<Thumbwheel
|
||||
v-model="cfg"
|
||||
@@ -1069,27 +1046,10 @@
|
||||
:step="CFG_STEP"
|
||||
@update:model-value="cfgTouched = true"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">{{ ltxVideo ? 'Default 3.5 for PinkCherry' : (turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity') }}. Hover and scroll ±0.1, Shift-scroll ±1. Drag the wheel up and down.</p>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">{{ ltxVideo ? 'Default 3.5 for PinkCherry' : (turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity') }}.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-zinc-400">Frame rate</span>
|
||||
<select v-model.number="fps" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||||
<option :value="24">24 FPS</option>
|
||||
<option :value="12">12 FPS</option>
|
||||
<option :value="30">30 FPS</option>
|
||||
</select>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">{{ frameCount }} frames · duration × FPS</p>
|
||||
</label>
|
||||
<DurationField
|
||||
v-model="duration"
|
||||
label="Duration"
|
||||
:hint="shotScriptMode
|
||||
? 'This length applies to the initial shot and every extension in the script.'
|
||||
: 'Type any length, or hover the wheel and scroll. Shift-scroll jumps by 1s. Drag up and down.'"
|
||||
/>
|
||||
</div>
|
||||
<label class="text-sm">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block text-zinc-400">Seed</span>
|
||||
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
|
||||
</label>
|
||||
@@ -1114,8 +1074,32 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
|
||||
<summary class="cursor-pointer text-sm font-medium">Aspect, steps, frame rate</summary>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<AspectRatioPick v-model="aspect" heading="Aspect ratio / resolution" :options="videoAspects" />
|
||||
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? (file ? ' · matching the still' : ' · 16:9 until a still is loaded') : '' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = true">{{ ltxVideo ? '8 Distilled LoRA' : (videoWorkflow === 'v2' ? '6 Turbo · LoRA on' : '8 Turbo · LoRA on') }}</button>
|
||||
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="!turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = false">{{ ltxVideo ? '12 Quality · LoRA on' : '20 High-Fidelity · LoRA bypass' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-zinc-400">Frame rate</span>
|
||||
<select v-model.number="fps" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||||
<option :value="24">24 FPS</option>
|
||||
<option :value="12">12 FPS</option>
|
||||
<option :value="30">30 FPS</option>
|
||||
</select>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">{{ frameCount }} frames · duration × FPS</p>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="studioMode === 'video' && queuedExtensionCount > 0"
|
||||
@@ -1228,8 +1212,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="min-w-0 max-w-full space-y-5">
|
||||
<div ref="outputPanel" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6">
|
||||
<section class="min-w-0 max-w-full space-y-5 lg:sticky lg:top-4 lg:flex lg:max-h-[calc(100dvh-5.5rem)] lg:flex-col lg:overflow-hidden">
|
||||
<div ref="outputPanel" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 lg:shrink-0">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="font-display text-2xl font-bold">Output</h2>
|
||||
@@ -1428,21 +1412,16 @@
|
||||
</p>
|
||||
</div>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 flex items-center gap-2 text-zinc-400">
|
||||
Extension prompt
|
||||
<KeepPromptMenu
|
||||
:items="keepPrompts.video"
|
||||
heading="Video prompts"
|
||||
align="left"
|
||||
:disabled="lastFrameLoading || videoBusy"
|
||||
@insert="insertKeepExtend"
|
||||
/>
|
||||
</span>
|
||||
<textarea
|
||||
<span class="mb-1 block text-zinc-400">Extension prompt</span>
|
||||
<PromptField
|
||||
v-model="extendPrompt"
|
||||
rows="4"
|
||||
class="w-full 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 focus:ring-2"
|
||||
:rows="4"
|
||||
title="Extension prompt"
|
||||
placeholder="Continue the motion from this frame…"
|
||||
:keep-items="keepPrompts.video"
|
||||
keep-heading="Video prompts"
|
||||
:disabled="lastFrameLoading || videoBusy"
|
||||
textarea-class=""
|
||||
/>
|
||||
</label>
|
||||
<DurationField
|
||||
@@ -1572,7 +1551,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-3xl p-5 sm:p-6">
|
||||
<div class="glass rounded-3xl p-5 sm:p-6 lg:flex lg:min-h-0 lg:flex-1 lg:flex-col">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="font-display text-xl font-bold">Library</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -1603,6 +1582,7 @@
|
||||
</form>
|
||||
|
||||
<template v-else>
|
||||
<div class="lg:flex lg:min-h-0 lg:flex-1 lg:flex-col">
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-wrap gap-1.5">
|
||||
<button
|
||||
@@ -1656,7 +1636,7 @@
|
||||
<button type="button" class="text-red-300 hover:text-red-200" @click="askRemoveSelected">Delete</button>
|
||||
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
|
||||
</div>
|
||||
<ul class="mt-4 max-h-[min(28rem,50vh)] space-y-3 overflow-y-auto">
|
||||
<ul class="mt-4 max-h-[min(28rem,50vh)] space-y-3 overflow-y-auto lg:min-h-0 lg:max-h-none lg:flex-1">
|
||||
<li
|
||||
v-for="entry in visibleLibraryEntries"
|
||||
:key="entry.key"
|
||||
@@ -1932,6 +1912,13 @@
|
||||
:disabled="keepingInputBusy"
|
||||
@click="saveStillAsInput(entry.stillGroup.head)"
|
||||
>Save as input</button>
|
||||
<button
|
||||
v-if="backupStudioEnabled"
|
||||
type="button"
|
||||
class="text-xs text-amber-300"
|
||||
:disabled="backupBusyId === entry.stillGroup.head.id"
|
||||
@click="sendStillToBackup(entry.stillGroup.head)"
|
||||
>{{ backupBusyId === entry.stillGroup.head.id ? 'Sending…' : 'Send to Backup on AIGen only' }}</button>
|
||||
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(entry.key)">
|
||||
{{ expandedDetails[entry.key] ? 'Hide details' : 'Details' }}
|
||||
</button>
|
||||
@@ -2008,6 +1995,13 @@
|
||||
:disabled="keepingInputBusy"
|
||||
@click="saveStillAsInput(item)"
|
||||
>Save as input</button>
|
||||
<button
|
||||
v-if="backupStudioEnabled"
|
||||
type="button"
|
||||
class="text-xs text-amber-300"
|
||||
:disabled="backupBusyId === item.id"
|
||||
@click="sendStillToBackup(item)"
|
||||
>{{ backupBusyId === item.id ? 'Sending…' : 'Send to Backup on AIGen only' }}</button>
|
||||
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(item.id)">
|
||||
{{ expandedDetails[item.id] ? 'Hide details' : 'Details' }}
|
||||
</button>
|
||||
@@ -2045,20 +2039,12 @@
|
||||
Showing {{ visibleLibraryEntries.length }} of {{ libraryEntries.length }}.
|
||||
<NuxtLink to="/library" class="text-amber-300 hover:text-amber-100">Open full library</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<PromptDrawer
|
||||
:open="promptDrawerOpen"
|
||||
v-model:text="prompt"
|
||||
:title="studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : (shotScriptMode ? 'Shot script' : 'Prompt')"
|
||||
:placeholder="promptPlaceholder"
|
||||
:keep-items="activeKeepPrompts"
|
||||
:keep-heading="keepPromptHeading"
|
||||
@update:open="promptDrawerOpen = $event"
|
||||
/>
|
||||
<ImageBatchModal
|
||||
:open="imageBatchOpen"
|
||||
v-model:text="imageBatchDraft"
|
||||
@@ -2155,6 +2141,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { composePromptParts, restorePromptParts } from '~/utils/promptParts'
|
||||
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
|
||||
import { parseImageBatchScript, parseShotScript } from '~/utils/parseShots'
|
||||
import type { DirectorScript } from '~/utils/directorScripts'
|
||||
@@ -2296,6 +2283,8 @@ interface LibraryStill {
|
||||
role?: 'input' | 'output'
|
||||
name?: string
|
||||
prompt?: string
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
familyId?: string
|
||||
parentStillId?: string
|
||||
chainIndex?: number
|
||||
@@ -2333,6 +2322,8 @@ interface LibraryClip {
|
||||
permanenceRefs?: PermanenceRef[]
|
||||
loraName?: string
|
||||
loraStack?: LoraStackItem[]
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
}
|
||||
|
||||
interface RetryDraft {
|
||||
@@ -2343,6 +2334,8 @@ interface RetryDraft {
|
||||
stillFilename?: string
|
||||
name?: string
|
||||
prompt: string
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
aspect: string
|
||||
width: number
|
||||
height: number
|
||||
@@ -2439,7 +2432,10 @@ const inputStillRenameEl = ref<HTMLInputElement | null>(null)
|
||||
const preview = ref('')
|
||||
const dragging = ref(false)
|
||||
const prompt = ref('')
|
||||
const promptDrawerOpen = ref(false)
|
||||
const promptPre = ref('')
|
||||
const promptPost = ref('')
|
||||
const backupBusyId = ref('')
|
||||
const backupStudioEnabled = computed(() => Boolean(runtimeConfig.public.backupStudioEnabled))
|
||||
const shotScriptMode = ref(false)
|
||||
const directorScripts = ref<DirectorScript[]>([])
|
||||
const directorScriptId = ref('')
|
||||
@@ -2983,7 +2979,7 @@ const canUseGeneratedStill = computed(() => Boolean(
|
||||
))
|
||||
const videoBlockReason = computed(() => {
|
||||
if (!textToVideo.value && !hasInputStill.value) return 'Load a still first. Image-to-video needs an input still.'
|
||||
if (!prompt.value.trim()) return 'Write a motion prompt first.'
|
||||
if (![promptPre.value, prompt.value, promptPost.value].some(part => part.trim())) return 'Write a motion prompt first.'
|
||||
if (!folderId.value) return 'Choose a library folder before generating.'
|
||||
if (!queueReady.value) {
|
||||
return shotScriptMode.value ? 'Each shot in the script needs a prompt.' : 'Each queued extension needs a prompt.'
|
||||
@@ -3135,7 +3131,7 @@ const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, i
|
||||
function composedShotPreview(text: string, index: number, shotN?: number) {
|
||||
const composed = composeShotPrompt({
|
||||
globalLocks: globalLocks.value,
|
||||
prompt: text,
|
||||
prompt: composePromptParts(promptPre.value, text, promptPost.value),
|
||||
shotIndex: index,
|
||||
familyRefs: familyPermanenceRefs.value,
|
||||
shotRefs: shotPermanenceRefs.value[shotN ?? index + 1]
|
||||
@@ -4095,6 +4091,21 @@ function insertKeepPrompt(item: KeepPrompt) {
|
||||
prompt.value = insertKeepSnippet(prompt.value, item.text)
|
||||
}
|
||||
|
||||
function applyPromptRestore(saved: string, stored?: { prompt?: string; promptPre?: string; promptPost?: string } | null) {
|
||||
const parts = restorePromptParts(saved, {
|
||||
pre: stored?.promptPre,
|
||||
prompt: stored?.prompt ?? saved,
|
||||
post: stored?.promptPost
|
||||
}, {
|
||||
pre: promptPre.value,
|
||||
prompt: prompt.value,
|
||||
post: promptPost.value
|
||||
})
|
||||
promptPre.value = parts.pre
|
||||
prompt.value = parts.prompt
|
||||
promptPost.value = parts.post
|
||||
}
|
||||
|
||||
function insertKeepExtend(item: KeepPrompt) {
|
||||
extendPrompt.value = insertKeepSnippet(extendPrompt.value, item.text)
|
||||
}
|
||||
@@ -4118,11 +4129,9 @@ function insertLoraTrigger(phrase: string, target: 'main' | 'extend' | { shot: n
|
||||
}
|
||||
if (typeof target === 'object' && 'shot' in target) {
|
||||
prompt.value = insertActivationInShotScript(prompt.value, target.shot, token)
|
||||
promptDrawerOpen.value = true
|
||||
return
|
||||
}
|
||||
prompt.value = insertActivationPhrase(prompt.value, token)
|
||||
promptDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function setShotLoraStack(shotN: number, stack: LoraStackItem[]) {
|
||||
@@ -4680,6 +4689,8 @@ function resetImage() {
|
||||
function clearVideoForm() {
|
||||
resetImage()
|
||||
prompt.value = ''
|
||||
promptPre.value = ''
|
||||
promptPost.value = ''
|
||||
clipName.value = ''
|
||||
extensionQueue.value = []
|
||||
shotScriptMode.value = false
|
||||
@@ -5115,6 +5126,8 @@ async function restoreStudioJob(id: string) {
|
||||
referenceStillIds?: Array<string | null>
|
||||
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
|
||||
queueAutoRun?: boolean
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
globalLocks?: string
|
||||
permanenceRefs?: PermanenceRef[]
|
||||
shotPermanenceRefs?: PermanenceRef[][]
|
||||
@@ -5131,9 +5144,9 @@ async function restoreStudioJob(id: string) {
|
||||
closeExtend()
|
||||
const extensions = payload.extensions || []
|
||||
applyVideoWorkflow(payload.workflow)
|
||||
prompt.value = extensions.length
|
||||
applyPromptRestore(extensions.length
|
||||
? [payload.prompt.trim(), ...extensions.map((item, index) => `shot ${index + 2}\n${item.prompt.trim()}`)].join('\n\n')
|
||||
: (payload.prompt || '')
|
||||
: (payload.prompt || ''), payload)
|
||||
clipName.value = payload.name || ''
|
||||
seedInput.value = String(payload.seed || '')
|
||||
restoreAdvanced({
|
||||
@@ -5516,7 +5529,7 @@ async function rerun(item: LibraryClip, collection = false) {
|
||||
const parts = familyPartsFor(item)
|
||||
const target = collection ? parts[0] || item : item
|
||||
const restoreAll = collection && parts.length > 1
|
||||
prompt.value = restoreAll ? familyScript(parts) : target.prompt
|
||||
applyPromptRestore(restoreAll ? familyScript(parts) : target.prompt, target)
|
||||
clipName.value = restoreAll
|
||||
? (familyBaseName(collectionTitle({ head: target, parts })) || target.name || '')
|
||||
: (target.name || '')
|
||||
@@ -5574,7 +5587,7 @@ async function rerun(item: LibraryClip, collection = false) {
|
||||
|
||||
async function loadDraft(draft: RetryDraft) {
|
||||
activeDraftId = draft.id
|
||||
prompt.value = draft.prompt
|
||||
applyPromptRestore(draft.prompt, draft)
|
||||
clipName.value = draft.name || ''
|
||||
seedInput.value = String(draft.seed)
|
||||
restoreAdvanced(draft)
|
||||
@@ -5711,6 +5724,7 @@ async function openExtend(item?: LibraryClip) {
|
||||
}
|
||||
currentClipId.value = id
|
||||
const clip = clips.value.find(entry => entry.id === id)
|
||||
if (clip) applyPromptRestore(clip.prompt, clip)
|
||||
extendPrompt.value = clip?.prompt || prompt.value
|
||||
extendDuration.value = clampDuration(duration.value)
|
||||
extendLoraStack.value = readLoraStack(clip)
|
||||
@@ -5750,6 +5764,8 @@ async function generateExtension() {
|
||||
body: {
|
||||
clipId: id,
|
||||
prompt: extendPrompt.value.trim(),
|
||||
promptPre: promptPre.value.trim() || undefined,
|
||||
promptPost: promptPost.value.trim() || undefined,
|
||||
duration: extendDuration.value,
|
||||
loraStack: extendLoraStack.value
|
||||
}
|
||||
@@ -5812,7 +5828,7 @@ function applyStillSettings(still: LibraryStill) {
|
||||
const settings = still.settings
|
||||
folderId.value = still.folderId
|
||||
browseFolderId.value = still.folderId
|
||||
if (still.prompt) prompt.value = still.prompt
|
||||
if (still.prompt || still.promptPre || still.promptPost) applyPromptRestore(still.prompt || '', still)
|
||||
if (dest === 'imagev2') {
|
||||
studioMode.value = 'editv2'
|
||||
outputFocus.value = 'edit'
|
||||
@@ -5889,6 +5905,19 @@ function stillUseAsInputMessage(still: LibraryStill) {
|
||||
return 'Loaded as input. It stays in Output, not the upload library.'
|
||||
}
|
||||
|
||||
async function sendStillToBackup(still: Pick<LibraryStill, 'id'>) {
|
||||
if (!backupStudioEnabled.value || backupBusyId.value) return
|
||||
backupBusyId.value = still.id
|
||||
try {
|
||||
await $fetch(`/api/library/stills/${still.id}/backup`, { method: 'POST' })
|
||||
toast('Sent to Backup as a hidden input.')
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Could not send that still to Backup')
|
||||
} finally {
|
||||
backupBusyId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStillAsInput(still: Pick<LibraryStill, 'id'>) {
|
||||
if (keepingInputBusy.value) return
|
||||
keepingInputBusy.value = true
|
||||
@@ -6470,6 +6499,8 @@ async function editImage() {
|
||||
body.append('image', file.value)
|
||||
if (editRefFile.value) body.append('image2', editRefFile.value)
|
||||
body.append('prompt', prompt.value.trim())
|
||||
if (promptPre.value.trim()) body.append('promptPre', promptPre.value.trim())
|
||||
if (promptPost.value.trim()) body.append('promptPost', promptPost.value.trim())
|
||||
body.append('aspect', aspect.value)
|
||||
body.append('negative', editNegative.value)
|
||||
body.append('steps', String(clampImageSteps(editSteps.value)))
|
||||
@@ -6707,6 +6738,8 @@ async function editImageV2() {
|
||||
body.append('aspect', aspect.value)
|
||||
body.append('engine', v2Engine.value)
|
||||
body.append('prompt', prompt.value.trim())
|
||||
if (promptPre.value.trim()) body.append('promptPre', promptPre.value.trim())
|
||||
if (promptPost.value.trim()) body.append('promptPost', promptPost.value.trim())
|
||||
body.append('negative', v2Negative.value)
|
||||
body.append('snofs_model', String(imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthModel ?? 0))
|
||||
body.append('snofs_clip', String(imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthClip ?? 0))
|
||||
@@ -6909,6 +6942,8 @@ async function generate() {
|
||||
if (file.value) body.append('image', file.value)
|
||||
if (inputStillId.value) body.append('stillId', inputStillId.value)
|
||||
body.append('prompt', initialPrompt)
|
||||
if (promptPre.value.trim()) body.append('promptPre', promptPre.value.trim())
|
||||
if (promptPost.value.trim()) body.append('promptPost', promptPost.value.trim())
|
||||
body.append('aspect', aspect.value)
|
||||
body.append('width', String(outputSize.value.width))
|
||||
body.append('height', String(outputSize.value.height))
|
||||
|
||||
@@ -271,6 +271,13 @@
|
||||
:disabled="keepingInputBusy"
|
||||
@click="saveFocusedAsInput"
|
||||
>{{ keepingInputBusy ? 'Saving…' : 'Save as input' }}</button>
|
||||
<button
|
||||
v-if="focused.kind === 'still' && backupStudioEnabled"
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
||||
:disabled="backupBusy"
|
||||
@click="sendFocusedToBackup"
|
||||
>{{ backupBusy ? 'Sending…' : 'Send to Backup on AIGen only' }}</button>
|
||||
<a
|
||||
v-if="downloadHref"
|
||||
:href="downloadHref"
|
||||
@@ -375,6 +382,8 @@ const filterOptions = [
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const instanceName = ref(String(runtimeConfig.public.instanceName || 'AIGen'))
|
||||
const backupStudioEnabled = computed(() => Boolean(runtimeConfig.public.backupStudioEnabled))
|
||||
const backupBusy = ref(false)
|
||||
const showPrivacyToggles = computed(() => isXaigenStudio({
|
||||
studio: runtimeConfig.public.studio,
|
||||
instanceName: runtimeConfig.public.instanceName || instanceName.value
|
||||
@@ -762,6 +771,20 @@ function useInStudio(entry: LibraryEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFocusedToBackup() {
|
||||
const still = focusedStill.value
|
||||
if (!still || !backupStudioEnabled.value || backupBusy.value) return
|
||||
backupBusy.value = true
|
||||
try {
|
||||
await $fetch(`/api/library/stills/${still.id}/backup`, { method: 'POST' })
|
||||
toast('Sent to Backup as a hidden input.')
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Could not send that still to Backup')
|
||||
} finally {
|
||||
backupBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFocusedAsInput() {
|
||||
const still = focusedStill.value
|
||||
if (!still || keepingInputBusy.value) return
|
||||
|
||||
@@ -34,6 +34,8 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const prompt = (fields.prompt || '').trim()
|
||||
const promptPre = (fields.promptPre || '').trim()
|
||||
const promptPost = (fields.promptPost || '').trim()
|
||||
const extraPasses = parsePasses(fields.passes)
|
||||
const passMode = parseImagePassMode(fields.passMode)
|
||||
const aspect = parseImageAspect(fields.aspect)
|
||||
@@ -127,6 +129,8 @@ export default defineEventHandler(async (event) => {
|
||||
kind: 'edit',
|
||||
payload: {
|
||||
prompt,
|
||||
promptPre: promptPre || undefined,
|
||||
promptPost: promptPost || undefined,
|
||||
name: clipName,
|
||||
folderId,
|
||||
aspect,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { copyFileSync, existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { composeShotPrompt } from '~/utils/globalLocks'
|
||||
import { composePromptParts } from '~/utils/promptParts'
|
||||
import { parsePostedLoraStack, listStudioLoras, persistLoraFields, ensureComfyLoraNames } from '~/server/utils/loras'
|
||||
import { readLoraStack } from '~/utils/loras'
|
||||
import { clampVideoCfg } from '~/utils/generationPresets'
|
||||
@@ -39,7 +40,7 @@ function parseScheduler(raw: unknown) {
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ clipId?: string; prompt?: 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(() => ({}))
|
||||
const clipId = String(body?.clipId || '').trim()
|
||||
const prompt = String(body?.prompt || '').trim()
|
||||
if (!clipId) {
|
||||
@@ -60,9 +61,11 @@ export default defineEventHandler(async (event) => {
|
||||
const destFolder = publicLibrary(event).folders.find(folder => folder.id === source.folderId)
|
||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||
const durationSeconds = parseExtendDuration(body?.duration)
|
||||
const promptPre = String(body?.promptPre ?? source.promptPre ?? '').trim()
|
||||
const promptPost = String(body?.promptPost ?? source.promptPost ?? '').trim()
|
||||
const composedPrompt = composeShotPrompt({
|
||||
globalLocks: source.globalLocks,
|
||||
prompt,
|
||||
prompt: composePromptParts(promptPre, prompt, promptPost),
|
||||
shotIndex: (source.chainIndex || 0) + 1,
|
||||
familyRefs: source.permanenceRefs
|
||||
})
|
||||
@@ -99,7 +102,10 @@ export default defineEventHandler(async (event) => {
|
||||
hideThumbnail: source.hideThumbnail,
|
||||
folderLocked,
|
||||
name: clipName,
|
||||
prompt: composedPrompt,
|
||||
prompt,
|
||||
promptMid: prompt,
|
||||
promptPre: promptPre || undefined,
|
||||
promptPost: promptPost || undefined,
|
||||
aspect: source.aspect || '16:9',
|
||||
width,
|
||||
height,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { LoraStackItem } from '~/utils/loras'
|
||||
import { clampVideoCfg } from '~/utils/generationPresets'
|
||||
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow, videoEngineOf } from '~/utils/videoModels'
|
||||
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
|
||||
import { composePromptParts } from '~/utils/promptParts'
|
||||
|
||||
function parseDuration(raw: unknown) {
|
||||
const seconds = Number(raw)
|
||||
@@ -137,11 +138,13 @@ export default defineEventHandler(async (event) => {
|
||||
if (!image && !textToVideo) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
|
||||
}
|
||||
const promptRaw = (fields.prompt || '').trim()
|
||||
if (!promptRaw) {
|
||||
const promptMid = (fields.prompt || '').trim()
|
||||
const promptPre = (fields.promptPre || '').trim()
|
||||
const promptPost = (fields.promptPost || '').trim()
|
||||
if (!composePromptParts(promptPre, promptMid, promptPost)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
|
||||
}
|
||||
const resolvedLocks = resolveGlobalLocks(fields.globalLocks || '', promptRaw)
|
||||
const resolvedLocks = resolveGlobalLocks(fields.globalLocks || '', promptMid)
|
||||
const prompt = resolvedLocks.prompt
|
||||
const globalLocks = resolvedLocks.locks
|
||||
const extensions = parseExtensions(fields.extensions)
|
||||
@@ -243,6 +246,9 @@ export default defineEventHandler(async (event) => {
|
||||
familyId,
|
||||
payload: {
|
||||
prompt,
|
||||
promptMid: promptMid || undefined,
|
||||
promptPre: promptPre || undefined,
|
||||
promptPost: promptPost || undefined,
|
||||
name: clipName,
|
||||
folderId,
|
||||
aspect: fields.aspect || 'auto',
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { passwordAuthEnabled, privateInstanceId } from '~/server/utils/localAuth'
|
||||
import { saveStill } from '~/server/utils/library'
|
||||
|
||||
function ingestOwner() {
|
||||
if (passwordAuthEnabled()) return privateInstanceId()
|
||||
return 'local'
|
||||
}
|
||||
|
||||
function assertIngestToken(event: Parameters<typeof getHeader>[0]) {
|
||||
const expected = String(useRuntimeConfig().backupIngestToken || process.env.BACKUP_INGEST_TOKEN || '').trim()
|
||||
if (!expected) {
|
||||
throw createError({ statusCode: 503, statusMessage: 'Backup ingest is not configured' })
|
||||
}
|
||||
const header = String(getHeader(event, 'authorization') || '')
|
||||
if (header !== `Bearer ${expected}`) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
assertIngestToken(event)
|
||||
const form = await readMultipartFormData(event)
|
||||
if (!form?.length) throw createError({ statusCode: 400, statusMessage: 'Expected an image upload' })
|
||||
const fields: Record<string, string> = {}
|
||||
let image: { filename: string; data: Buffer } | null = null
|
||||
for (const part of form) {
|
||||
if (part.name === 'image' && part.filename && part.data?.length) {
|
||||
image = { filename: part.filename, data: part.data }
|
||||
} else if (part.name && part.data) {
|
||||
fields[part.name] = part.data.toString('utf8')
|
||||
}
|
||||
}
|
||||
if (!image) throw createError({ statusCode: 400, statusMessage: 'An image is required' })
|
||||
const owner = ingestOwner()
|
||||
const still = await saveStill({
|
||||
ownerKey: owner,
|
||||
folderId: 'inbox',
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width: Number(fields.width || 0),
|
||||
height: Number(fields.height || 0),
|
||||
hideInput: true,
|
||||
role: 'input',
|
||||
name: (fields.name || '').trim().slice(0, 80) || undefined
|
||||
})
|
||||
if (!still) throw createError({ statusCode: 400, statusMessage: 'Backup studio already has that still' })
|
||||
return { ok: true, id: still.id }
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { isXaigenStudio } from '~/utils/videoModels'
|
||||
import { assertLibraryOwner, getStill, stillPath } from '~/server/utils/library'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (isXaigenStudio()) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Not found' })
|
||||
}
|
||||
const config = useRuntimeConfig()
|
||||
const url = String(config.backupStudioUrl || process.env.BACKUP_STUDIO_URL || '').replace(/\/$/, '')
|
||||
const token = String(config.backupStudioToken || process.env.BACKUP_STUDIO_TOKEN || '').trim()
|
||||
if (!url || !token) {
|
||||
throw createError({ statusCode: 503, statusMessage: 'Backup studio is not configured' })
|
||||
}
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const still = getStill(owner, id)
|
||||
const path = stillPath(owner, still.id)
|
||||
const data = readFileSync(path)
|
||||
const body = new FormData()
|
||||
body.append('image', new Blob([Uint8Array.from(data)]), still.filename || 'still.png')
|
||||
body.append('name', still.name || still.filename || 'still')
|
||||
body.append('width', String(still.width || 0))
|
||||
body.append('height', String(still.height || 0))
|
||||
const res = await fetch(`${url}/api/library/backup-ingest`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body
|
||||
})
|
||||
if (!res.ok) {
|
||||
const message = await res.text().catch(() => '')
|
||||
throw createError({
|
||||
statusCode: res.status === 401 ? 502 : res.status,
|
||||
statusMessage: message || 'Backup studio refused the still'
|
||||
})
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -136,6 +136,8 @@ export default defineEventHandler(async (event) => {
|
||||
mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene'
|
||||
)
|
||||
const prompt = String(fields.prompt || '').trim()
|
||||
const promptPre = String(fields.promptPre || '').trim()
|
||||
const promptPost = String(fields.promptPost || '').trim()
|
||||
const extraPasses = parsePasses(fields.passes)
|
||||
const passMode = parseImagePassMode(fields.passMode)
|
||||
const aspect = parseImageAspect(fields.aspect)
|
||||
@@ -305,6 +307,8 @@ export default defineEventHandler(async (event) => {
|
||||
kind: 'edit',
|
||||
payload: {
|
||||
prompt,
|
||||
promptPre: promptPre || undefined,
|
||||
promptPost: promptPost || undefined,
|
||||
name: clipName,
|
||||
folderId,
|
||||
aspect: mode === 'generate' ? generateSize.aspect : aspect,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { imageDimensions } from '~/server/utils/resolution'
|
||||
import { emitChainJob } from '~/server/utils/watch'
|
||||
import { saveStill, stillChainName } from '~/server/utils/library'
|
||||
import { comfyJobPrefix } from '~/utils/outputNames'
|
||||
import { composePromptParts } from '~/utils/promptParts'
|
||||
|
||||
export type EditImageFile = { filename: string; data: Buffer; type?: string }
|
||||
|
||||
@@ -66,7 +67,8 @@ export async function runEdit(job: Job, params: EditRunParams) {
|
||||
for (let index = 0; index < prompts.length; index++) {
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
if (index > 0 && library.stopAfterCurrent === true) break
|
||||
const prompt = (batch || index === 0) ? composeFillPrompt(prompts[index], filled.padded) : prompts[index]
|
||||
const mid = (batch || index === 0) ? composeFillPrompt(prompts[index], filled.padded) : prompts[index]
|
||||
const prompt = composePromptParts(library.promptPre || '', mid, library.promptPost || '')
|
||||
const last = index === prompts.length - 1
|
||||
if (index > 0) {
|
||||
await ensureComfyReady((status) => {
|
||||
@@ -79,7 +81,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
|
||||
}, { skipBusyWait: true })
|
||||
}
|
||||
const seed = index === 0 ? params.seed : Math.floor(Math.random() * 2_147_483_647)
|
||||
library.prompt = prompt
|
||||
library.prompt = mid
|
||||
library.seed = seed
|
||||
library.chainIndex = index
|
||||
library.chainStep = index + 1
|
||||
@@ -164,7 +166,9 @@ export async function runEdit(job: Job, params: EditRunParams) {
|
||||
hideInput: job.hideThumbnail === true,
|
||||
role: 'output',
|
||||
name: passName || undefined,
|
||||
prompt,
|
||||
prompt: mid,
|
||||
promptPre: library.promptPre,
|
||||
promptPost: library.promptPost,
|
||||
familyId: library.familyId,
|
||||
parentStillId: batch ? library.stillId : parentStillId,
|
||||
chainIndex: index,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { imageDimensions } from '~/server/utils/resolution'
|
||||
import { emitChainJob } from '~/server/utils/watch'
|
||||
import { saveStill, stillChainName } from '~/server/utils/library'
|
||||
import { comfyJobPrefix } from '~/utils/outputNames'
|
||||
import { composePromptParts } from '~/utils/promptParts'
|
||||
import { parseImageV2Engine, type ImageV2Engine, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2'
|
||||
import type { EditImageFile } from '~/server/utils/imageChain'
|
||||
|
||||
@@ -194,7 +195,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
|
||||
mode: params.mode,
|
||||
engine,
|
||||
task: params.task,
|
||||
prompt,
|
||||
prompt: composePromptParts(library.promptPre || '', prompt, library.promptPost || ''),
|
||||
negative: params.negative,
|
||||
imageAName: uploaded?.name,
|
||||
imageBName: uploadedRef?.name,
|
||||
@@ -264,6 +265,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
|
||||
role: 'output',
|
||||
name: passName || undefined,
|
||||
prompt,
|
||||
promptPre: library.promptPre,
|
||||
promptPost: library.promptPost,
|
||||
familyId: library.familyId,
|
||||
parentStillId: batch ? (params.sourceStillId || library.stillId) : parentStillId,
|
||||
chainIndex: index,
|
||||
|
||||
@@ -53,6 +53,9 @@ export interface Job {
|
||||
folderLocked?: boolean
|
||||
name?: string
|
||||
prompt: string
|
||||
promptMid?: string
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
aspect: string
|
||||
width: number
|
||||
height: number
|
||||
|
||||
+19
-1
@@ -58,6 +58,8 @@ export interface LibraryClip {
|
||||
permanenceRefs?: PermanenceRef[]
|
||||
loraName?: string
|
||||
loraStack?: import('~/utils/loras').LoraStackItem[]
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
}
|
||||
|
||||
export type StillRole = 'input' | 'output'
|
||||
@@ -74,6 +76,8 @@ export interface LibraryStill {
|
||||
role: StillRole
|
||||
name?: string
|
||||
prompt?: string
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
familyId?: string
|
||||
parentStillId?: string
|
||||
chainIndex?: number
|
||||
@@ -220,6 +224,8 @@ function normalizeStill(still: LibraryStill): LibraryStill {
|
||||
role: stillRole(still),
|
||||
name: still.name,
|
||||
prompt: still.prompt,
|
||||
promptPre: still.promptPre,
|
||||
promptPost: still.promptPost,
|
||||
familyId: still.familyId,
|
||||
parentStillId: still.parentStillId,
|
||||
chainIndex: still.chainIndex,
|
||||
@@ -1210,6 +1216,8 @@ export async function saveStill(params: {
|
||||
role?: StillRole
|
||||
name?: string
|
||||
prompt?: string
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
familyId?: string
|
||||
parentStillId?: string
|
||||
chainIndex?: number
|
||||
@@ -1238,6 +1246,8 @@ export async function saveStill(params: {
|
||||
item.role = role
|
||||
if (name) item.name = name
|
||||
if (prompt) item.prompt = prompt
|
||||
if (params.promptPre != null) item.promptPre = params.promptPre || undefined
|
||||
if (params.promptPost != null) item.promptPost = params.promptPost || undefined
|
||||
if (params.familyId) item.familyId = params.familyId
|
||||
if (params.parentStillId) item.parentStillId = params.parentStillId
|
||||
if (typeof params.chainIndex === 'number') item.chainIndex = params.chainIndex
|
||||
@@ -1247,6 +1257,8 @@ export async function saveStill(params: {
|
||||
existing.role = role
|
||||
if (name) existing.name = name
|
||||
if (prompt) existing.prompt = prompt
|
||||
if (params.promptPre != null) existing.promptPre = params.promptPre || undefined
|
||||
if (params.promptPost != null) existing.promptPost = params.promptPost || undefined
|
||||
if (nextSettings) existing.settings = nextSettings
|
||||
}
|
||||
return existing
|
||||
@@ -1266,6 +1278,8 @@ export async function saveStill(params: {
|
||||
role,
|
||||
name: params.name?.trim().slice(0, 80) || undefined,
|
||||
prompt: params.prompt || undefined,
|
||||
promptPre: params.promptPre || undefined,
|
||||
promptPost: params.promptPost || undefined,
|
||||
familyId: params.familyId,
|
||||
parentStillId: params.parentStillId,
|
||||
chainIndex: params.chainIndex,
|
||||
@@ -1554,6 +1568,8 @@ export async function saveClip(params: {
|
||||
permanenceRefs?: PermanenceRef[]
|
||||
loraName?: string
|
||||
loraStack?: import('~/utils/loras').LoraStackItem[]
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
segmentFirstFrame?: Buffer
|
||||
}) {
|
||||
const catalog = readCatalog(params.ownerKey)
|
||||
@@ -1587,7 +1603,9 @@ export async function saveClip(params: {
|
||||
globalLocks: params.globalLocks,
|
||||
permanenceRefs: params.permanenceRefs?.length ? normalizePermanenceRefs(params.permanenceRefs) : undefined,
|
||||
loraName: params.loraName || undefined,
|
||||
loraStack: params.loraStack?.length ? params.loraStack : undefined
|
||||
loraStack: params.loraStack?.length ? params.loraStack : undefined,
|
||||
promptPre: params.promptPre || undefined,
|
||||
promptPost: params.promptPost || undefined
|
||||
}
|
||||
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
||||
const videoPath = clipVideoPath(params.ownerKey, clip.id)
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface PendingJob {
|
||||
folderLocked?: boolean
|
||||
name?: string
|
||||
prompt: string
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
aspect: string
|
||||
width: number
|
||||
height: number
|
||||
@@ -107,6 +109,8 @@ export function pendingFromJob(job: Job, overrides: Partial<PendingJob> = {}): P
|
||||
folderLocked: library.folderLocked,
|
||||
name: library.name,
|
||||
prompt: library.prompt,
|
||||
promptPre: library.promptPre,
|
||||
promptPost: library.promptPost,
|
||||
aspect: library.aspect,
|
||||
width: library.width,
|
||||
height: library.height,
|
||||
@@ -164,6 +168,8 @@ export function libraryFromPending(pending: PendingJob): NonNullable<Job['librar
|
||||
folderLocked: pending.folderLocked,
|
||||
name: pending.name,
|
||||
prompt: pending.prompt,
|
||||
promptPre: pending.promptPre,
|
||||
promptPost: pending.promptPost,
|
||||
aspect: pending.aspect,
|
||||
width: pending.width,
|
||||
height: pending.height,
|
||||
@@ -313,6 +319,8 @@ export async function completePendingIfReady(pending: PendingJob) {
|
||||
folderId: pending.folderId,
|
||||
name: pending.name,
|
||||
prompt: pending.prompt,
|
||||
promptPre: pending.promptPre,
|
||||
promptPost: pending.promptPost,
|
||||
aspect: pending.aspect,
|
||||
width: pending.width,
|
||||
height: pending.height,
|
||||
|
||||
@@ -14,6 +14,9 @@ export type StudioJobKind = 'video' | 'edit'
|
||||
|
||||
export interface StudioJobPayload {
|
||||
prompt: string
|
||||
promptMid?: string
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
name: string
|
||||
folderId: string
|
||||
aspect: string
|
||||
@@ -942,6 +945,9 @@ export async function startStudioJob(item: StudioJob) {
|
||||
folderLocked: payload.folderLocked,
|
||||
name: payload.name,
|
||||
prompt: payload.prompt,
|
||||
promptMid: payload.promptMid,
|
||||
promptPre: payload.promptPre,
|
||||
promptPost: payload.promptPost,
|
||||
aspect: payload.aspect,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
updateShotQueue
|
||||
} from '~/server/utils/shotQueue'
|
||||
import { composeShotPrompt, allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||
import { composePromptParts } from '~/utils/promptParts'
|
||||
import { persistLoraFields, ensureComfyLoraNames } from '~/server/utils/loras'
|
||||
import { readLoraStack, resolveLoraStack } from '~/utils/loras'
|
||||
import type { LoraStackItem } from '~/utils/loras'
|
||||
@@ -186,7 +187,7 @@ export async function queueMiniMax(
|
||||
const shotIndex = job.library?.chainIndex || 0
|
||||
const composedPrompt = composeShotPrompt({
|
||||
globalLocks: job.library?.globalLocks || params.globalLocks,
|
||||
prompt: params.prompt,
|
||||
prompt: composePromptParts(job.library?.promptPre || '', params.prompt, job.library?.promptPost || ''),
|
||||
shotIndex,
|
||||
familyRefs: job.library?.permanenceRefs || params.permanenceRefs,
|
||||
shotRefs: job.library?.shotPermanenceRefs?.[shotIndex] || params.shotPermanenceRefs?.[shotIndex]
|
||||
|
||||
@@ -216,7 +216,9 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
||||
ownerKey: job.library.ownerKey,
|
||||
folderId: job.library.folderId,
|
||||
name: job.library.name,
|
||||
prompt: job.library.prompt,
|
||||
prompt: job.library.promptMid || job.library.prompt,
|
||||
promptPre: job.library.promptPre,
|
||||
promptPost: job.library.promptPost,
|
||||
aspect: job.library.aspect,
|
||||
width: job.library.width,
|
||||
height: job.library.height,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export type PromptParts = {
|
||||
pre: string
|
||||
prompt: string
|
||||
post: string
|
||||
}
|
||||
|
||||
export function sanitizePromptPart(raw: unknown) {
|
||||
return String(raw || '').replace(/\r\n/g, '\n').replace(/\r/g, '')
|
||||
}
|
||||
|
||||
export function composePromptParts(pre: string, prompt: string, post: string) {
|
||||
return [pre, prompt, post]
|
||||
.map(part => sanitizePromptPart(part).trim())
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export function applyPromptParts(parts: PromptParts, middle: string) {
|
||||
return composePromptParts(parts.pre, middle, parts.post)
|
||||
}
|
||||
|
||||
export function restorePromptParts(saved: string, stored?: Partial<PromptParts> | null, current?: Partial<PromptParts> | null): PromptParts {
|
||||
const pre = sanitizePromptPart(stored?.pre ?? '').trim()
|
||||
const post = sanitizePromptPart(stored?.post ?? '').trim()
|
||||
if (pre || post) {
|
||||
return {
|
||||
pre,
|
||||
prompt: sanitizePromptPart(stored?.prompt ?? saved).trim() || sanitizePromptPart(saved).trim(),
|
||||
post
|
||||
}
|
||||
}
|
||||
let mid = sanitizePromptPart(stored?.prompt ?? saved)
|
||||
const curPre = sanitizePromptPart(current?.pre || '').trim()
|
||||
const curPost = sanitizePromptPart(current?.post || '').trim()
|
||||
if (curPre && mid.startsWith(curPre)) mid = mid.slice(curPre.length).replace(/^\n+/, '')
|
||||
if (curPost && mid.endsWith(curPost)) mid = mid.slice(0, Math.max(0, mid.length - curPost.length)).replace(/\n+$/, '')
|
||||
return {
|
||||
pre: current?.pre || '',
|
||||
prompt: mid.trim(),
|
||||
post: current?.post || ''
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user