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/
|
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
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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 || '',
|
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
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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