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:
Towsty
2026-09-01 18:39:02 -05:00
co-authored by Cursor
parent ed04dcfee3
commit f788fd4bb4
22 changed files with 753 additions and 199 deletions
+5
View File
@@ -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/ NUXT_OIDC_TOKEN_URL=http://192.168.77.2:9000/application/o/token/
LIBRARY_DIR=/data/library 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 # Private clone (xaigen): password login, isolated library, Comfy prefix
# AUTH_MODE=password # AUTH_MODE=password
# NUXT_PUBLIC_INSTANCE_NAME=xAIGen # NUXT_PUBLIC_INSTANCE_NAME=xAIGen
+36 -21
View File
@@ -1,5 +1,5 @@
<template> <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"> <summary class="cursor-pointer text-sm font-medium text-zinc-200">
Global locks Global locks
<span class="ml-2 font-normal text-xs text-zinc-500">Every shot inherits these. Each clip adds one beat.</span> <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"> <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. 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> </p>
<textarea <div class="mt-3">
:value="modelValue" <PromptField
rows="10" :model-value="modelValue"
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" :rows="10"
title="Global locks"
write-label="Write locks"
edit-label="View / edit locks"
textarea-class="max-h-64"
:placeholder="placeholder" :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"> <div class="mt-2 flex flex-wrap gap-2">
<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>
type="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>
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" </div>
@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> </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> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -39,6 +53,7 @@ import { DEFAULT_GLOBAL_LOCKS } from '~/utils/globalLocks'
defineProps<{ defineProps<{
modelValue: string modelValue: string
embedded?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
+71
View File
@@ -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>
+208
View File
@@ -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
View File
@@ -52,6 +52,9 @@ export default defineNuxtConfig({
authMode: process.env.NUXT_AUTH_MODE || process.env.AUTH_MODE || '', authMode: process.env.NUXT_AUTH_MODE || process.env.AUTH_MODE || '',
authUsername: process.env.AUTH_USERNAME || '', authUsername: process.env.AUTH_USERNAME || '',
authPassword: process.env.AUTH_PASSWORD || '', 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', purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS !== 'false',
comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3', comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3',
kreaConceptLora: process.env.NUXT_KREA_CONCEPT_LORA || '', kreaConceptLora: process.env.NUXT_KREA_CONCEPT_LORA || '',
@@ -61,7 +64,8 @@ export default defineNuxtConfig({
instanceName: process.env.NUXT_PUBLIC_INSTANCE_NAME || 'AIGen', instanceName: process.env.NUXT_PUBLIC_INSTANCE_NAME || 'AIGen',
// NUXT_PUBLIC_STUDIO=xaigen on the xAIGen Coolify app (LTX Pink Cherry + xAIGen-prefixed LoRAs) // NUXT_PUBLIC_STUDIO=xaigen on the xAIGen Coolify app (LTX Pink Cherry + xAIGen-prefixed LoRAs)
studio: process.env.NUXT_PUBLIC_STUDIO || '', 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: { nitro: {
+162 -127
View File
@@ -7,7 +7,7 @@
@gpu="pollComfyHealth" @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"> <section id="studio-input" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
<div> <div>
<div class="mb-4 grid grid-cols-2 gap-3"> <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"> <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> <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"> <div class="flex flex-wrap items-center gap-3">
<KeepPromptMenu
:items="activeKeepPrompts"
:heading="keepPromptHeading"
align="right"
@insert="insertKeepPrompt"
/>
<button <button
v-if="identityPrompting" v-if="identityPrompting"
type="button" type="button"
@@ -284,21 +278,15 @@
:class="studioMode === 'edit' || studioMode === 'editv2' ? 'md:grid-cols-2' : ''" :class="studioMode === 'edit' || studioMode === 'editv2' ? 'md:grid-cols-2' : ''"
> >
<div class="min-w-0"> <div class="min-w-0">
<textarea <PromptPartsEditor
v-model="prompt" v-model:pre="promptPre"
:rows="shotScriptMode || identityPrompting ? 12 : 6" v-model:prompt="prompt"
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" v-model:post="promptPost"
:placeholder="promptPlaceholder" :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> </div>
<label v-if="studioMode === 'edit' || studioMode === 'editv2'" class="block min-w-0 text-sm"> <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> <span class="mb-2 block font-medium text-zinc-300">Negative prompt</span>
@@ -318,18 +306,6 @@
/> />
</label> </label>
</div> </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> <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"> <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> <p class="text-xs text-zinc-500">Prompt only what should change in the painted area.</p>
@@ -432,11 +408,14 @@
Remove Remove
</button> </button>
</div> </div>
<textarea <PromptField
v-model="item.prompt" v-model="item.prompt"
rows="3" :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" :title="`${imagePassMode === 'batch' ? 'Prompt' : 'Pass'} ${index + 2}`"
:placeholder="extraPassPlaceholder" :placeholder="extraPassPlaceholder"
:keep-items="activeKeepPrompts"
:keep-heading="keepPromptHeading"
:disabled="editBusy"
/> />
</div> </div>
<button <button
@@ -458,15 +437,20 @@
<p class="mt-3 text-[10px] uppercase tracking-wide text-zinc-500">Concatenated result</p> <p class="mt-3 text-[10px] uppercase tracking-wide text-zinc-500">Concatenated result</p>
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedIdentityPrompt }}</p> <p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedIdentityPrompt }}</p>
</details> </details>
<div v-if="studioMode === 'video'" class="mt-3 space-y-3"> <details v-if="studioMode === 'video'" class="mt-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<GlobalLocksEditor v-model="globalLocks" /> <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"> <div class="rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2.5">
<PermanenceRefStrip <PermanenceRefStrip
:refs="familyPermanenceRefs" :refs="familyPermanenceRefs"
:name-options="lockBindingKeys" :name-options="lockBindingKeys"
label="Permanence refs · all shots" label="Named stills · all shots"
hint="Name + still" hint="Name + still"
cta="Add permanence refs" cta="Add a named still"
add-title="Choose a still to name for every shot" add-title="Choose a still to name for every shot"
:hidden="hideInputPreview" :hidden="hideInputPreview"
@add-ref="openPermanencePicker()" @add-ref="openPermanencePicker()"
@@ -474,12 +458,13 @@
@update-label="updateFamilyRefLabel" @update-label="updateFamilyRefLabel"
/> />
<p class="mt-2 text-[11px] leading-relaxed text-zinc-500"> <p class="mt-2 text-[11px] leading-relaxed text-zinc-500">
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>
<p class="mt-1 text-[11px] leading-relaxed text-zinc-500">{{ permanenceComfyNote }}</p> <p class="mt-1 text-[11px] leading-relaxed text-zinc-500">{{ permanenceComfyNote }}</p>
<p v-if="familyRefLabelLine" class="mt-1 text-[11px] text-amber-200/80">{{ familyRefLabelLine }}</p> <p v-if="familyRefLabelLine" class="mt-1 text-[11px] text-amber-200/80">{{ familyRefLabelLine }}</p>
</div> </div>
</div> </div>
</details>
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 space-y-2"> <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> <p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
<div <div
@@ -532,16 +517,7 @@
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4" 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="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> <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 <button
type="button" type="button"
class="text-xs text-red-300 hover:text-red-200" class="text-xs text-red-300 hover:text-red-200"
@@ -551,11 +527,14 @@
Remove Remove
</button> </button>
</div> </div>
<textarea <PromptField
v-model="item.prompt" v-model="item.prompt"
rows="3" :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" :title="`Extension #${index + 1}`"
placeholder="Motion prompt for this extension…" 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" /> <DurationField v-model="item.duration" class="mt-3" label="Duration" />
<LoraSelect <LoraSelect
@@ -737,11 +716,13 @@
</div> </div>
</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"> <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)">
<div> <summary class="cursor-pointer text-sm font-medium text-zinc-200">
<p class="text-sm font-medium text-zinc-200">Character stills</p> Character stills
<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> <span class="ml-2 font-normal text-xs text-zinc-500">Optional extra views of the same person. Off unless you need them.</span>
</div> </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-if="!extraPersonLock" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div <div
v-for="slot in 4" 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-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> <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> </div>
</details>
</div> </div>
<div v-if="studioMode === 'video'" class="max-w-md space-y-2"> <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> <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> </div>
<details v-if="studioMode === 'video'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3"> <div v-if="studioMode === 'video'" class="space-y-4">
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="mt-4 space-y-4"> <DurationField
<div> v-model="duration"
<AspectRatioPick v-model="aspect" heading="Aspect ratio / resolution" :options="videoAspects" /> label="Duration"
<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> :hint="shotScriptMode
</div> ? 'This length applies to the initial shot and every extension in the script.'
<div> : 'Type any length, or hover the wheel and scroll. Shift-scroll jumps by 1s. Drag up and down.'"
<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 class="text-sm"> <div class="text-sm">
<Thumbwheel <Thumbwheel
v-model="cfg" v-model="cfg"
@@ -1069,27 +1046,10 @@
:step="CFG_STEP" :step="CFG_STEP"
@update:model-value="cfgTouched = true" @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>
<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> </div>
<label class="text-sm"> <label class="block text-sm">
<span class="mb-1 block text-zinc-400">Seed</span> <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"> <input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
</label> </label>
@@ -1114,8 +1074,32 @@
</label> </label>
</div> </div>
</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> </div>
</details> </details>
</div>
<label <label
v-if="studioMode === 'video' && queuedExtensionCount > 0" v-if="studioMode === 'video' && queuedExtensionCount > 0"
@@ -1228,8 +1212,8 @@
</div> </div>
</section> </section>
<section class="min-w-0 max-w-full space-y-5"> <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"> <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 justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h2 class="font-display text-2xl font-bold">Output</h2> <h2 class="font-display text-2xl font-bold">Output</h2>
@@ -1428,21 +1412,16 @@
</p> </p>
</div> </div>
<label class="block text-sm"> <label class="block text-sm">
<span class="mb-1 flex items-center gap-2 text-zinc-400"> <span class="mb-1 block text-zinc-400">Extension prompt</span>
Extension prompt <PromptField
<KeepPromptMenu
:items="keepPrompts.video"
heading="Video prompts"
align="left"
:disabled="lastFrameLoading || videoBusy"
@insert="insertKeepExtend"
/>
</span>
<textarea
v-model="extendPrompt" v-model="extendPrompt"
rows="4" :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" title="Extension prompt"
placeholder="Continue the motion from this frame…" placeholder="Continue the motion from this frame…"
:keep-items="keepPrompts.video"
keep-heading="Video prompts"
:disabled="lastFrameLoading || videoBusy"
textarea-class=""
/> />
</label> </label>
<DurationField <DurationField
@@ -1572,7 +1551,7 @@
</div> </div>
</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"> <div class="flex items-center justify-between gap-3">
<h2 class="font-display text-xl font-bold">Library</h2> <h2 class="font-display text-xl font-bold">Library</h2>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -1603,6 +1582,7 @@
</form> </form>
<template v-else> <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="mt-4 flex flex-wrap items-center justify-between gap-2">
<div class="flex min-w-0 flex-wrap gap-1.5"> <div class="flex min-w-0 flex-wrap gap-1.5">
<button <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-red-300 hover:text-red-200" @click="askRemoveSelected">Delete</button>
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button> <button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
</div> </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 <li
v-for="entry in visibleLibraryEntries" v-for="entry in visibleLibraryEntries"
:key="entry.key" :key="entry.key"
@@ -1932,6 +1912,13 @@
:disabled="keepingInputBusy" :disabled="keepingInputBusy"
@click="saveStillAsInput(entry.stillGroup.head)" @click="saveStillAsInput(entry.stillGroup.head)"
>Save as input</button> >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)"> <button type="button" class="text-xs text-zinc-400" @click="toggleDetails(entry.key)">
{{ expandedDetails[entry.key] ? 'Hide details' : 'Details' }} {{ expandedDetails[entry.key] ? 'Hide details' : 'Details' }}
</button> </button>
@@ -2008,6 +1995,13 @@
:disabled="keepingInputBusy" :disabled="keepingInputBusy"
@click="saveStillAsInput(item)" @click="saveStillAsInput(item)"
>Save as input</button> >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)"> <button type="button" class="text-xs text-zinc-400" @click="toggleDetails(item.id)">
{{ expandedDetails[item.id] ? 'Hide details' : 'Details' }} {{ expandedDetails[item.id] ? 'Hide details' : 'Details' }}
</button> </button>
@@ -2045,20 +2039,12 @@
Showing {{ visibleLibraryEntries.length }} of {{ libraryEntries.length }}. Showing {{ visibleLibraryEntries.length }} of {{ libraryEntries.length }}.
<NuxtLink to="/library" class="text-amber-300 hover:text-amber-100">Open full library</NuxtLink> <NuxtLink to="/library" class="text-amber-300 hover:text-amber-100">Open full library</NuxtLink>
</p> </p>
</div>
</template> </template>
</div> </div>
</section> </section>
</main> </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 <ImageBatchModal
:open="imageBatchOpen" :open="imageBatchOpen"
v-model:text="imageBatchDraft" v-model:text="imageBatchDraft"
@@ -2155,6 +2141,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { composePromptParts, restorePromptParts } from '~/utils/promptParts'
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend' import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
import { parseImageBatchScript, parseShotScript } from '~/utils/parseShots' import { parseImageBatchScript, parseShotScript } from '~/utils/parseShots'
import type { DirectorScript } from '~/utils/directorScripts' import type { DirectorScript } from '~/utils/directorScripts'
@@ -2296,6 +2283,8 @@ interface LibraryStill {
role?: 'input' | 'output' role?: 'input' | 'output'
name?: string name?: string
prompt?: string prompt?: string
promptPre?: string
promptPost?: string
familyId?: string familyId?: string
parentStillId?: string parentStillId?: string
chainIndex?: number chainIndex?: number
@@ -2333,6 +2322,8 @@ interface LibraryClip {
permanenceRefs?: PermanenceRef[] permanenceRefs?: PermanenceRef[]
loraName?: string loraName?: string
loraStack?: LoraStackItem[] loraStack?: LoraStackItem[]
promptPre?: string
promptPost?: string
} }
interface RetryDraft { interface RetryDraft {
@@ -2343,6 +2334,8 @@ interface RetryDraft {
stillFilename?: string stillFilename?: string
name?: string name?: string
prompt: string prompt: string
promptPre?: string
promptPost?: string
aspect: string aspect: string
width: number width: number
height: number height: number
@@ -2439,7 +2432,10 @@ const inputStillRenameEl = ref<HTMLInputElement | null>(null)
const preview = ref('') const preview = ref('')
const dragging = ref(false) const dragging = ref(false)
const prompt = ref('') 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 shotScriptMode = ref(false)
const directorScripts = ref<DirectorScript[]>([]) const directorScripts = ref<DirectorScript[]>([])
const directorScriptId = ref('') const directorScriptId = ref('')
@@ -2983,7 +2979,7 @@ const canUseGeneratedStill = computed(() => Boolean(
)) ))
const videoBlockReason = computed(() => { const videoBlockReason = computed(() => {
if (!textToVideo.value && !hasInputStill.value) return 'Load a still first. Image-to-video needs an input still.' 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 (!folderId.value) return 'Choose a library folder before generating.'
if (!queueReady.value) { if (!queueReady.value) {
return shotScriptMode.value ? 'Each shot in the script needs a prompt.' : 'Each queued extension needs a prompt.' 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) { function composedShotPreview(text: string, index: number, shotN?: number) {
const composed = composeShotPrompt({ const composed = composeShotPrompt({
globalLocks: globalLocks.value, globalLocks: globalLocks.value,
prompt: text, prompt: composePromptParts(promptPre.value, text, promptPost.value),
shotIndex: index, shotIndex: index,
familyRefs: familyPermanenceRefs.value, familyRefs: familyPermanenceRefs.value,
shotRefs: shotPermanenceRefs.value[shotN ?? index + 1] shotRefs: shotPermanenceRefs.value[shotN ?? index + 1]
@@ -4095,6 +4091,21 @@ function insertKeepPrompt(item: KeepPrompt) {
prompt.value = insertKeepSnippet(prompt.value, item.text) 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) { function insertKeepExtend(item: KeepPrompt) {
extendPrompt.value = insertKeepSnippet(extendPrompt.value, item.text) 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) { if (typeof target === 'object' && 'shot' in target) {
prompt.value = insertActivationInShotScript(prompt.value, target.shot, token) prompt.value = insertActivationInShotScript(prompt.value, target.shot, token)
promptDrawerOpen.value = true
return return
} }
prompt.value = insertActivationPhrase(prompt.value, token) prompt.value = insertActivationPhrase(prompt.value, token)
promptDrawerOpen.value = true
} }
function setShotLoraStack(shotN: number, stack: LoraStackItem[]) { function setShotLoraStack(shotN: number, stack: LoraStackItem[]) {
@@ -4680,6 +4689,8 @@ function resetImage() {
function clearVideoForm() { function clearVideoForm() {
resetImage() resetImage()
prompt.value = '' prompt.value = ''
promptPre.value = ''
promptPost.value = ''
clipName.value = '' clipName.value = ''
extensionQueue.value = [] extensionQueue.value = []
shotScriptMode.value = false shotScriptMode.value = false
@@ -5115,6 +5126,8 @@ async function restoreStudioJob(id: string) {
referenceStillIds?: Array<string | null> referenceStillIds?: Array<string | null>
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[] extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
queueAutoRun?: boolean queueAutoRun?: boolean
promptPre?: string
promptPost?: string
globalLocks?: string globalLocks?: string
permanenceRefs?: PermanenceRef[] permanenceRefs?: PermanenceRef[]
shotPermanenceRefs?: PermanenceRef[][] shotPermanenceRefs?: PermanenceRef[][]
@@ -5131,9 +5144,9 @@ async function restoreStudioJob(id: string) {
closeExtend() closeExtend()
const extensions = payload.extensions || [] const extensions = payload.extensions || []
applyVideoWorkflow(payload.workflow) 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.trim(), ...extensions.map((item, index) => `shot ${index + 2}\n${item.prompt.trim()}`)].join('\n\n')
: (payload.prompt || '') : (payload.prompt || ''), payload)
clipName.value = payload.name || '' clipName.value = payload.name || ''
seedInput.value = String(payload.seed || '') seedInput.value = String(payload.seed || '')
restoreAdvanced({ restoreAdvanced({
@@ -5516,7 +5529,7 @@ async function rerun(item: LibraryClip, collection = false) {
const parts = familyPartsFor(item) const parts = familyPartsFor(item)
const target = collection ? parts[0] || item : item const target = collection ? parts[0] || item : item
const restoreAll = collection && parts.length > 1 const restoreAll = collection && parts.length > 1
prompt.value = restoreAll ? familyScript(parts) : target.prompt applyPromptRestore(restoreAll ? familyScript(parts) : target.prompt, target)
clipName.value = restoreAll clipName.value = restoreAll
? (familyBaseName(collectionTitle({ head: target, parts })) || target.name || '') ? (familyBaseName(collectionTitle({ head: target, parts })) || target.name || '')
: (target.name || '') : (target.name || '')
@@ -5574,7 +5587,7 @@ async function rerun(item: LibraryClip, collection = false) {
async function loadDraft(draft: RetryDraft) { async function loadDraft(draft: RetryDraft) {
activeDraftId = draft.id activeDraftId = draft.id
prompt.value = draft.prompt applyPromptRestore(draft.prompt, draft)
clipName.value = draft.name || '' clipName.value = draft.name || ''
seedInput.value = String(draft.seed) seedInput.value = String(draft.seed)
restoreAdvanced(draft) restoreAdvanced(draft)
@@ -5711,6 +5724,7 @@ async function openExtend(item?: LibraryClip) {
} }
currentClipId.value = id currentClipId.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)
extendPrompt.value = clip?.prompt || prompt.value extendPrompt.value = clip?.prompt || prompt.value
extendDuration.value = clampDuration(duration.value) extendDuration.value = clampDuration(duration.value)
extendLoraStack.value = readLoraStack(clip) extendLoraStack.value = readLoraStack(clip)
@@ -5750,6 +5764,8 @@ async function generateExtension() {
body: { body: {
clipId: id, clipId: id,
prompt: extendPrompt.value.trim(), prompt: extendPrompt.value.trim(),
promptPre: promptPre.value.trim() || undefined,
promptPost: promptPost.value.trim() || undefined,
duration: extendDuration.value, duration: extendDuration.value,
loraStack: extendLoraStack.value loraStack: extendLoraStack.value
} }
@@ -5812,7 +5828,7 @@ function applyStillSettings(still: LibraryStill) {
const settings = still.settings const settings = still.settings
folderId.value = still.folderId folderId.value = still.folderId
browseFolderId.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') { if (dest === 'imagev2') {
studioMode.value = 'editv2' studioMode.value = 'editv2'
outputFocus.value = 'edit' outputFocus.value = 'edit'
@@ -5889,6 +5905,19 @@ function stillUseAsInputMessage(still: LibraryStill) {
return 'Loaded as input. It stays in Output, not the upload library.' 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'>) { async function saveStillAsInput(still: Pick<LibraryStill, 'id'>) {
if (keepingInputBusy.value) return if (keepingInputBusy.value) return
keepingInputBusy.value = true keepingInputBusy.value = true
@@ -6470,6 +6499,8 @@ async function editImage() {
body.append('image', file.value) body.append('image', file.value)
if (editRefFile.value) body.append('image2', editRefFile.value) if (editRefFile.value) body.append('image2', editRefFile.value)
body.append('prompt', prompt.value.trim()) 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('aspect', aspect.value)
body.append('negative', editNegative.value) body.append('negative', editNegative.value)
body.append('steps', String(clampImageSteps(editSteps.value))) body.append('steps', String(clampImageSteps(editSteps.value)))
@@ -6707,6 +6738,8 @@ async function editImageV2() {
body.append('aspect', aspect.value) body.append('aspect', aspect.value)
body.append('engine', v2Engine.value) body.append('engine', v2Engine.value)
body.append('prompt', prompt.value.trim()) 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('negative', v2Negative.value)
body.append('snofs_model', String(imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthModel ?? 0)) 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)) 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 (file.value) body.append('image', file.value)
if (inputStillId.value) body.append('stillId', inputStillId.value) if (inputStillId.value) body.append('stillId', inputStillId.value)
body.append('prompt', initialPrompt) 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('aspect', aspect.value)
body.append('width', String(outputSize.value.width)) body.append('width', String(outputSize.value.width))
body.append('height', String(outputSize.value.height)) body.append('height', String(outputSize.value.height))
+23
View File
@@ -271,6 +271,13 @@
:disabled="keepingInputBusy" :disabled="keepingInputBusy"
@click="saveFocusedAsInput" @click="saveFocusedAsInput"
>{{ keepingInputBusy ? 'Saving…' : 'Save as input' }}</button> >{{ 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 <a
v-if="downloadHref" v-if="downloadHref"
:href="downloadHref" :href="downloadHref"
@@ -375,6 +382,8 @@ const filterOptions = [
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const instanceName = ref(String(runtimeConfig.public.instanceName || 'AIGen')) const instanceName = ref(String(runtimeConfig.public.instanceName || 'AIGen'))
const backupStudioEnabled = computed(() => Boolean(runtimeConfig.public.backupStudioEnabled))
const backupBusy = ref(false)
const showPrivacyToggles = computed(() => isXaigenStudio({ const showPrivacyToggles = computed(() => isXaigenStudio({
studio: runtimeConfig.public.studio, studio: runtimeConfig.public.studio,
instanceName: runtimeConfig.public.instanceName || instanceName.value 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() { async function saveFocusedAsInput() {
const still = focusedStill.value const still = focusedStill.value
if (!still || keepingInputBusy.value) return if (!still || keepingInputBusy.value) return
+4
View File
@@ -34,6 +34,8 @@ export default defineEventHandler(async (event) => {
} }
const prompt = (fields.prompt || '').trim() const prompt = (fields.prompt || '').trim()
const promptPre = (fields.promptPre || '').trim()
const promptPost = (fields.promptPost || '').trim()
const extraPasses = parsePasses(fields.passes) const extraPasses = parsePasses(fields.passes)
const passMode = parseImagePassMode(fields.passMode) const passMode = parseImagePassMode(fields.passMode)
const aspect = parseImageAspect(fields.aspect) const aspect = parseImageAspect(fields.aspect)
@@ -127,6 +129,8 @@ export default defineEventHandler(async (event) => {
kind: 'edit', kind: 'edit',
payload: { payload: {
prompt, prompt,
promptPre: promptPre || undefined,
promptPost: promptPost || undefined,
name: clipName, name: clipName,
folderId, folderId,
aspect, aspect,
+9 -3
View File
@@ -1,6 +1,7 @@
import { copyFileSync, existsSync, readFileSync } from 'node:fs' import { copyFileSync, existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { composeShotPrompt } from '~/utils/globalLocks' import { composeShotPrompt } from '~/utils/globalLocks'
import { composePromptParts } from '~/utils/promptParts'
import { parsePostedLoraStack, listStudioLoras, persistLoraFields, ensureComfyLoraNames } from '~/server/utils/loras' import { parsePostedLoraStack, listStudioLoras, persistLoraFields, ensureComfyLoraNames } from '~/server/utils/loras'
import { readLoraStack } from '~/utils/loras' import { readLoraStack } from '~/utils/loras'
import { clampVideoCfg } from '~/utils/generationPresets' import { clampVideoCfg } from '~/utils/generationPresets'
@@ -39,7 +40,7 @@ function parseScheduler(raw: unknown) {
} }
export default defineEventHandler(async (event) => { 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 clipId = String(body?.clipId || '').trim()
const prompt = String(body?.prompt || '').trim() const prompt = String(body?.prompt || '').trim()
if (!clipId) { if (!clipId) {
@@ -60,9 +61,11 @@ 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 promptPost = String(body?.promptPost ?? source.promptPost ?? '').trim()
const composedPrompt = composeShotPrompt({ const composedPrompt = composeShotPrompt({
globalLocks: source.globalLocks, globalLocks: source.globalLocks,
prompt, prompt: composePromptParts(promptPre, prompt, promptPost),
shotIndex: (source.chainIndex || 0) + 1, shotIndex: (source.chainIndex || 0) + 1,
familyRefs: source.permanenceRefs familyRefs: source.permanenceRefs
}) })
@@ -99,7 +102,10 @@ export default defineEventHandler(async (event) => {
hideThumbnail: source.hideThumbnail, hideThumbnail: source.hideThumbnail,
folderLocked, folderLocked,
name: clipName, name: clipName,
prompt: composedPrompt, prompt,
promptMid: prompt,
promptPre: promptPre || undefined,
promptPost: promptPost || undefined,
aspect: source.aspect || '16:9', aspect: source.aspect || '16:9',
width, width,
height, height,
+9 -3
View File
@@ -5,6 +5,7 @@ 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'
function parseDuration(raw: unknown) { function parseDuration(raw: unknown) {
const seconds = Number(raw) const seconds = Number(raw)
@@ -137,11 +138,13 @@ export default defineEventHandler(async (event) => {
if (!image && !textToVideo) { if (!image && !textToVideo) {
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' }) throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
} }
const promptRaw = (fields.prompt || '').trim() const promptMid = (fields.prompt || '').trim()
if (!promptRaw) { 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' }) 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 prompt = resolvedLocks.prompt
const globalLocks = resolvedLocks.locks const globalLocks = resolvedLocks.locks
const extensions = parseExtensions(fields.extensions) const extensions = parseExtensions(fields.extensions)
@@ -243,6 +246,9 @@ export default defineEventHandler(async (event) => {
familyId, familyId,
payload: { payload: {
prompt, prompt,
promptMid: promptMid || undefined,
promptPre: promptPre || undefined,
promptPost: promptPost || undefined,
name: clipName, name: clipName,
folderId, folderId,
aspect: fields.aspect || 'auto', aspect: fields.aspect || 'auto',
+48
View File
@@ -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 }
})
+4
View File
@@ -136,6 +136,8 @@ export default defineEventHandler(async (event) => {
mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene' mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene'
) )
const prompt = String(fields.prompt || '').trim() const prompt = String(fields.prompt || '').trim()
const promptPre = String(fields.promptPre || '').trim()
const promptPost = String(fields.promptPost || '').trim()
const extraPasses = parsePasses(fields.passes) const extraPasses = parsePasses(fields.passes)
const passMode = parseImagePassMode(fields.passMode) const passMode = parseImagePassMode(fields.passMode)
const aspect = parseImageAspect(fields.aspect) const aspect = parseImageAspect(fields.aspect)
@@ -305,6 +307,8 @@ export default defineEventHandler(async (event) => {
kind: 'edit', kind: 'edit',
payload: { payload: {
prompt, prompt,
promptPre: promptPre || undefined,
promptPost: promptPost || undefined,
name: clipName, name: clipName,
folderId, folderId,
aspect: mode === 'generate' ? generateSize.aspect : aspect, aspect: mode === 'generate' ? generateSize.aspect : aspect,
+7 -3
View File
@@ -8,6 +8,7 @@ import { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch' import { emitChainJob } from '~/server/utils/watch'
import { saveStill, stillChainName } from '~/server/utils/library' import { saveStill, stillChainName } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames' import { comfyJobPrefix } from '~/utils/outputNames'
import { composePromptParts } from '~/utils/promptParts'
export type EditImageFile = { filename: string; data: Buffer; type?: string } 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++) { for (let index = 0; index < prompts.length; index++) {
if (job.status === 'cancelled') throw new Error('Job interrupted.') if (job.status === 'cancelled') throw new Error('Job interrupted.')
if (index > 0 && library.stopAfterCurrent === true) break 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 const last = index === prompts.length - 1
if (index > 0) { if (index > 0) {
await ensureComfyReady((status) => { await ensureComfyReady((status) => {
@@ -79,7 +81,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
}, { skipBusyWait: true }) }, { skipBusyWait: true })
} }
const seed = index === 0 ? params.seed : Math.floor(Math.random() * 2_147_483_647) const seed = index === 0 ? params.seed : Math.floor(Math.random() * 2_147_483_647)
library.prompt = prompt library.prompt = mid
library.seed = seed library.seed = seed
library.chainIndex = index library.chainIndex = index
library.chainStep = index + 1 library.chainStep = index + 1
@@ -164,7 +166,9 @@ export async function runEdit(job: Job, params: EditRunParams) {
hideInput: job.hideThumbnail === true, hideInput: job.hideThumbnail === true,
role: 'output', role: 'output',
name: passName || undefined, name: passName || undefined,
prompt, prompt: mid,
promptPre: library.promptPre,
promptPost: library.promptPost,
familyId: library.familyId, familyId: library.familyId,
parentStillId: batch ? library.stillId : parentStillId, parentStillId: batch ? library.stillId : parentStillId,
chainIndex: index, chainIndex: index,
+4 -1
View File
@@ -8,6 +8,7 @@ import { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch' import { emitChainJob } from '~/server/utils/watch'
import { saveStill, stillChainName } from '~/server/utils/library' import { saveStill, stillChainName } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames' import { comfyJobPrefix } from '~/utils/outputNames'
import { composePromptParts } from '~/utils/promptParts'
import { parseImageV2Engine, type ImageV2Engine, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2' import { parseImageV2Engine, type ImageV2Engine, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2'
import type { EditImageFile } from '~/server/utils/imageChain' import type { EditImageFile } from '~/server/utils/imageChain'
@@ -194,7 +195,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
mode: params.mode, mode: params.mode,
engine, engine,
task: params.task, task: params.task,
prompt, prompt: composePromptParts(library.promptPre || '', prompt, library.promptPost || ''),
negative: params.negative, negative: params.negative,
imageAName: uploaded?.name, imageAName: uploaded?.name,
imageBName: uploadedRef?.name, imageBName: uploadedRef?.name,
@@ -264,6 +265,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
role: 'output', role: 'output',
name: passName || undefined, name: passName || undefined,
prompt, prompt,
promptPre: library.promptPre,
promptPost: library.promptPost,
familyId: library.familyId, familyId: library.familyId,
parentStillId: batch ? (params.sourceStillId || library.stillId) : parentStillId, parentStillId: batch ? (params.sourceStillId || library.stillId) : parentStillId,
chainIndex: index, chainIndex: index,
+3
View File
@@ -53,6 +53,9 @@ export interface Job {
folderLocked?: boolean folderLocked?: boolean
name?: string name?: string
prompt: string prompt: string
promptMid?: string
promptPre?: string
promptPost?: string
aspect: string aspect: string
width: number width: number
height: number height: number
+19 -1
View File
@@ -58,6 +58,8 @@ export interface LibraryClip {
permanenceRefs?: PermanenceRef[] permanenceRefs?: PermanenceRef[]
loraName?: string loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[] loraStack?: import('~/utils/loras').LoraStackItem[]
promptPre?: string
promptPost?: string
} }
export type StillRole = 'input' | 'output' export type StillRole = 'input' | 'output'
@@ -74,6 +76,8 @@ export interface LibraryStill {
role: StillRole role: StillRole
name?: string name?: string
prompt?: string prompt?: string
promptPre?: string
promptPost?: string
familyId?: string familyId?: string
parentStillId?: string parentStillId?: string
chainIndex?: number chainIndex?: number
@@ -220,6 +224,8 @@ function normalizeStill(still: LibraryStill): LibraryStill {
role: stillRole(still), role: stillRole(still),
name: still.name, name: still.name,
prompt: still.prompt, prompt: still.prompt,
promptPre: still.promptPre,
promptPost: still.promptPost,
familyId: still.familyId, familyId: still.familyId,
parentStillId: still.parentStillId, parentStillId: still.parentStillId,
chainIndex: still.chainIndex, chainIndex: still.chainIndex,
@@ -1210,6 +1216,8 @@ export async function saveStill(params: {
role?: StillRole role?: StillRole
name?: string name?: string
prompt?: string prompt?: string
promptPre?: string
promptPost?: string
familyId?: string familyId?: string
parentStillId?: string parentStillId?: string
chainIndex?: number chainIndex?: number
@@ -1238,6 +1246,8 @@ export async function saveStill(params: {
item.role = role item.role = role
if (name) item.name = name if (name) item.name = name
if (prompt) item.prompt = prompt 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.familyId) item.familyId = params.familyId
if (params.parentStillId) item.parentStillId = params.parentStillId if (params.parentStillId) item.parentStillId = params.parentStillId
if (typeof params.chainIndex === 'number') item.chainIndex = params.chainIndex if (typeof params.chainIndex === 'number') item.chainIndex = params.chainIndex
@@ -1247,6 +1257,8 @@ export async function saveStill(params: {
existing.role = role existing.role = role
if (name) existing.name = name if (name) existing.name = name
if (prompt) existing.prompt = prompt 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 if (nextSettings) existing.settings = nextSettings
} }
return existing return existing
@@ -1266,6 +1278,8 @@ export async function saveStill(params: {
role, role,
name: params.name?.trim().slice(0, 80) || undefined, name: params.name?.trim().slice(0, 80) || undefined,
prompt: params.prompt || undefined, prompt: params.prompt || undefined,
promptPre: params.promptPre || undefined,
promptPost: params.promptPost || undefined,
familyId: params.familyId, familyId: params.familyId,
parentStillId: params.parentStillId, parentStillId: params.parentStillId,
chainIndex: params.chainIndex, chainIndex: params.chainIndex,
@@ -1554,6 +1568,8 @@ export async function saveClip(params: {
permanenceRefs?: PermanenceRef[] permanenceRefs?: PermanenceRef[]
loraName?: string loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[] loraStack?: import('~/utils/loras').LoraStackItem[]
promptPre?: string
promptPost?: string
segmentFirstFrame?: Buffer segmentFirstFrame?: Buffer
}) { }) {
const catalog = readCatalog(params.ownerKey) const catalog = readCatalog(params.ownerKey)
@@ -1587,7 +1603,9 @@ export async function saveClip(params: {
globalLocks: params.globalLocks, globalLocks: params.globalLocks,
permanenceRefs: params.permanenceRefs?.length ? normalizePermanenceRefs(params.permanenceRefs) : undefined, permanenceRefs: params.permanenceRefs?.length ? normalizePermanenceRefs(params.permanenceRefs) : undefined,
loraName: params.loraName || 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 }) mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
const videoPath = clipVideoPath(params.ownerKey, clip.id) const videoPath = clipVideoPath(params.ownerKey, clip.id)
+8
View File
@@ -14,6 +14,8 @@ export interface PendingJob {
folderLocked?: boolean folderLocked?: boolean
name?: string name?: string
prompt: string prompt: string
promptPre?: string
promptPost?: string
aspect: string aspect: string
width: number width: number
height: number height: number
@@ -107,6 +109,8 @@ export function pendingFromJob(job: Job, overrides: Partial<PendingJob> = {}): P
folderLocked: library.folderLocked, folderLocked: library.folderLocked,
name: library.name, name: library.name,
prompt: library.prompt, prompt: library.prompt,
promptPre: library.promptPre,
promptPost: library.promptPost,
aspect: library.aspect, aspect: library.aspect,
width: library.width, width: library.width,
height: library.height, height: library.height,
@@ -164,6 +168,8 @@ export function libraryFromPending(pending: PendingJob): NonNullable<Job['librar
folderLocked: pending.folderLocked, folderLocked: pending.folderLocked,
name: pending.name, name: pending.name,
prompt: pending.prompt, prompt: pending.prompt,
promptPre: pending.promptPre,
promptPost: pending.promptPost,
aspect: pending.aspect, aspect: pending.aspect,
width: pending.width, width: pending.width,
height: pending.height, height: pending.height,
@@ -313,6 +319,8 @@ export async function completePendingIfReady(pending: PendingJob) {
folderId: pending.folderId, folderId: pending.folderId,
name: pending.name, name: pending.name,
prompt: pending.prompt, prompt: pending.prompt,
promptPre: pending.promptPre,
promptPost: pending.promptPost,
aspect: pending.aspect, aspect: pending.aspect,
width: pending.width, width: pending.width,
height: pending.height, height: pending.height,
+6
View File
@@ -14,6 +14,9 @@ export type StudioJobKind = 'video' | 'edit'
export interface StudioJobPayload { export interface StudioJobPayload {
prompt: string prompt: string
promptMid?: string
promptPre?: string
promptPost?: string
name: string name: string
folderId: string folderId: string
aspect: string aspect: string
@@ -942,6 +945,9 @@ export async function startStudioJob(item: StudioJob) {
folderLocked: payload.folderLocked, folderLocked: payload.folderLocked,
name: payload.name, name: payload.name,
prompt: payload.prompt, prompt: payload.prompt,
promptMid: payload.promptMid,
promptPre: payload.promptPre,
promptPost: payload.promptPost,
aspect: payload.aspect, aspect: payload.aspect,
width: payload.width, width: payload.width,
height: payload.height, height: payload.height,
+2 -1
View File
@@ -22,6 +22,7 @@ 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 { 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'
@@ -186,7 +187,7 @@ export async function queueMiniMax(
const shotIndex = job.library?.chainIndex || 0 const shotIndex = job.library?.chainIndex || 0
const composedPrompt = composeShotPrompt({ const composedPrompt = composeShotPrompt({
globalLocks: job.library?.globalLocks || params.globalLocks, globalLocks: job.library?.globalLocks || params.globalLocks,
prompt: params.prompt, prompt: composePromptParts(job.library?.promptPre || '', params.prompt, job.library?.promptPost || ''),
shotIndex, shotIndex,
familyRefs: job.library?.permanenceRefs || params.permanenceRefs, familyRefs: job.library?.permanenceRefs || params.permanenceRefs,
shotRefs: job.library?.shotPermanenceRefs?.[shotIndex] || params.shotPermanenceRefs?.[shotIndex] shotRefs: job.library?.shotPermanenceRefs?.[shotIndex] || params.shotPermanenceRefs?.[shotIndex]
+3 -1
View File
@@ -216,7 +216,9 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
ownerKey: job.library.ownerKey, ownerKey: job.library.ownerKey,
folderId: job.library.folderId, folderId: job.library.folderId,
name: job.library.name, 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, aspect: job.library.aspect,
width: job.library.width, width: job.library.width,
height: job.library.height, height: job.library.height,
+42
View File
@@ -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 || ''
}
}