Add a Director section to save and reload multi-shot video scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-29 17:25:57 -05:00
co-authored by Cursor
parent 334bbba8af
commit 852b07bb97
11 changed files with 779 additions and 2 deletions
+160 -2
View File
@@ -22,6 +22,12 @@
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">
Settings
</button>
<NuxtLink
to="/director"
class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"
>
Director
</NuxtLink>
<NuxtLink
to="/queue"
class="rounded-full border px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"
@@ -241,8 +247,19 @@
</label>
</div>
</div>
<DirectorScriptBar
v-if="studioMode === 'video'"
class="mb-2"
:scripts="directorScripts"
v-model:selected-id="directorScriptId"
v-model:name="directorScriptName"
:disabled="videoBusy"
@load="loadDirectorScript"
@save="saveDirectorScript"
@remove="deleteDirectorScript"
/>
<p v-if="shotScriptMode && studioMode === 'video'" class="mb-2 text-xs text-zinc-500">
Paste a block. Text before the first marker is shot 1. Put <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span> on their own lines to queue extensions — a line like <span class="font-medium text-zinc-300">Shot Type</span> will not start a new shot. Every shot uses the duration slider. Global locks are inherited; each shot should add one beat.
Paste a block, or load a saved Director script. Text before the first marker is shot 1. Put <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span> on their own lines to queue extensions — a line like <span class="font-medium text-zinc-300">Shot Type</span> will not start a new shot. Every shot uses the duration slider. Global locks are inherited; each shot should add one beat.
<span v-if="identityPrompting"> The Comfy identity prefix is prepended to every shot, and the same Picture 1 / extra stills are sent with each shot.</span>
</p>
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
@@ -733,6 +750,16 @@
</button>
</div>
<p class="mt-2 text-xs text-zinc-500">Flux is Klein 9B. Krea is Krea 2 Turbo. Video stays MiniMax or LTX.</p>
<div class="max-w-md">
<LoraSelect
v-model="imageV2LoraStack"
:names="imageV2LoraOptions"
label="LoRA"
empty-label="None"
compact
/>
<p v-if="v2Engine === 'krea'" class="mt-1 text-[11px] text-zinc-500">Flux LoRAs stay off on Krea.</p>
</div>
<div v-if="v2Mode === 'generate'" class="space-y-3 rounded-2xl border border-white/10 bg-zinc-950/40 p-3">
<div>
<p class="text-xs text-zinc-400">Size · near 1 MP</p>
@@ -1929,6 +1956,7 @@
<script setup lang="ts">
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
import { parseShotScript } from '~/utils/parseShots'
import type { DirectorScript } from '~/utils/directorScripts'
import {
composeShotPrompt,
extractGlobalLocks,
@@ -1942,7 +1970,12 @@ import {
type PermanenceRef
} from '~/utils/globalLocks'
import {
filterLoraStackForImageEngine,
filterLoraStackForStudio,
filterLorasForImageEngine,
isKleinImageLora,
kleinLoraOnKreaMessage,
loraDisplayName,
loraStacksEqual,
normalizeLoraStack,
persistLoraFields,
@@ -2024,6 +2057,8 @@ import {
} from '~/utils/videoModels'
const runtimeConfig = useRuntimeConfig()
const route = useRoute()
const router = useRouter()
type AspectId = 'auto' | '16:9' | '9:16' | '1:1'
@@ -2169,6 +2204,10 @@ const dragging = ref(false)
const prompt = ref('')
const promptDrawerOpen = ref(false)
const shotScriptMode = ref(false)
const directorScripts = ref<DirectorScript[]>([])
const directorScriptId = ref('')
const directorScriptName = ref('')
const DIRECTOR_IMPORT_KEY = 'aigen-director-import'
const recommendPresetId = ref('enhance')
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean }>>([
{ id: 'enhance', name: 'Enhance', builtin: true },
@@ -2194,6 +2233,7 @@ const imageHeight = ref(0)
const turbo = ref(true)
const videoLoraStack = ref<LoraStackItem[]>([])
const imageLoraStack = ref<LoraStackItem[]>([])
const imageV2LoraStack = ref<LoraStackItem[]>([])
const videoLoras = ref<string[]>([])
const imageLoras = ref<string[]>([])
const shotLoraStacks = ref<Record<number, LoraStackItem[]>>({})
@@ -2400,6 +2440,10 @@ const videoLoraOptions = computed(() => mergeLoraOptions(videoLoras.value, [
...extensionQueue.value.flatMap(item => (item.loraStack || []).map(entry => entry.name))
]))
const imageLoraOptions = computed(() => mergeLoraOptions(imageLoras.value, imageLoraStack.value.map(item => item.name)))
const imageV2LoraOptions = computed(() => mergeLoraOptions(
filterLorasForImageEngine(imageLoras.value, v2Engine.value),
imageV2LoraStack.value.map(item => item.name)
))
const useIdentityRefs = ref(false)
const identityRefs = ref<(File | null)[]>([null, null, null, null])
const identityPreviews = ref(['', '', '', ''])
@@ -3233,6 +3277,7 @@ onMounted(async () => {
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
videoLoraStack.value = readStoredLoraStack('aigen-video-lora')
imageLoraStack.value = readStoredLoraStack('aigen-image-lora')
imageV2LoraStack.value = readStoredLoraStack('aigen-image-v2-lora')
globalLocks.value = localStorage.getItem(LOCKS_STORE) || ''
try {
const savedRefs = JSON.parse(localStorage.getItem(REFS_STORE) || '')
@@ -3253,6 +3298,7 @@ onMounted(async () => {
applyComfyHealth(health)
void pollLoras()
void loadGenerationPresets()
await loadDirectorScripts().catch(() => null)
userName.value = me.user?.name || me.user?.email || ''
authEnabled.value = Boolean(me.authEnabled)
authMode.value = me.authMode || ''
@@ -3270,6 +3316,7 @@ onMounted(async () => {
await refreshStudioQueue()
await refreshLiveQueue()
await loadPromptPresets()
applyIncomingDirectorScript()
})
onBeforeUnmount(() => {
@@ -3405,6 +3452,10 @@ watch(imageLoraStack, (value) => {
try { localStorage.setItem('aigen-image-lora', JSON.stringify(value)) } catch { /* ignore */ }
}, { deep: true })
watch(imageV2LoraStack, (value) => {
try { localStorage.setItem('aigen-image-v2-lora', JSON.stringify(value)) } catch { /* ignore */ }
}, { deep: true })
function clampDuration(value: number) {
if (!Number.isFinite(value)) return 5
return Math.min(120, Math.max(0.5, Math.round(value * 10) / 10))
@@ -3487,6 +3538,84 @@ const currentPresetKind = computed(() => (
studioMode.value === 'editv2' ? 'imagev2' : studioMode.value === 'edit' ? 'image' : 'video'
))
async function loadDirectorScripts() {
const listed = await $fetch<{ scripts?: DirectorScript[] }>('/api/director-scripts').catch(() => ({ scripts: [] }))
directorScripts.value = listed.scripts || []
}
function applyDirectorScript(script: { id?: string; name?: string; text: string }) {
prompt.value = script.text
studioMode.value = 'video'
outputFocus.value = 'video'
shotScriptMode.value = true
directorScriptId.value = script.id || ''
directorScriptName.value = script.name || ''
}
function loadDirectorScript(id: string) {
const script = directorScripts.value.find(item => item.id === id)
if (!script) return
applyDirectorScript(script)
}
async function saveDirectorScript() {
const name = directorScriptName.value.trim()
if (!name) {
toast('Name the script before saving.')
return
}
if (!prompt.value.trim()) {
toast('Write a shot script before saving.')
return
}
const existing = directorScripts.value.find(item => item.name.toLowerCase() === name.toLowerCase())
if (existing && !window.confirm(`Overwrite script “${existing.name}”?`)) return
try {
const saved = await $fetch<{ script: DirectorScript; scripts: DirectorScript[] }>('/api/director-scripts', {
method: 'POST',
body: { name, text: prompt.value, overwrite: Boolean(existing) }
})
directorScripts.value = saved.scripts
directorScriptId.value = saved.script.id
directorScriptName.value = saved.script.name
toast(existing ? `Overwrote “${saved.script.name}”.` : `Saved “${saved.script.name}”.`)
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not save that script')
}
}
async function deleteDirectorScript() {
const id = directorScriptId.value
const script = directorScripts.value.find(item => item.id === id)
if (!id || !script) return
if (!window.confirm(`Delete script “${script.name}”?`)) return
try {
const saved = await $fetch<{ scripts: DirectorScript[] }>(`/api/director-scripts/${id}`, { method: 'DELETE' })
directorScripts.value = saved.scripts
directorScriptId.value = ''
toast(`Deleted “${script.name}”.`)
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not delete that script')
}
}
function applyIncomingDirectorScript() {
const scriptId = String(route.query.script || '').trim()
if (scriptId) {
const script = directorScripts.value.find(item => item.id === scriptId)
if (script) applyDirectorScript(script)
if (route.query.script) void router.replace({ path: '/', query: {} })
return
}
try {
const imported = sessionStorage.getItem(DIRECTOR_IMPORT_KEY)
if (imported) {
sessionStorage.removeItem(DIRECTOR_IMPORT_KEY)
applyDirectorScript({ text: imported })
}
} catch { /* ignore */ }
}
async function loadGenerationPresets() {
const [video, image, imagev2] = await Promise.all([
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'video' } }).catch(() => ({ presets: [] })),
@@ -3502,7 +3631,10 @@ function currentPresetSnapshot() {
if (studioMode.value === 'editv2') {
return {
kind: 'imagev2' as const,
loraStack: [],
loraStack: filterLoraStackForImageEngine(
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
v2Engine.value
),
settings: {
mode: v2Mode.value,
engine: v2Engine.value,
@@ -3575,6 +3707,7 @@ function applyGenerationPreset(preset: GenerationPreset) {
v2Width.value = size.width
v2Height.value = size.height
}
imageV2LoraStack.value = filterLoraStackForImageEngine(stack, v2Engine.value)
void ensureRefineCanvas()
imageV2PresetId.value = preset.id
imageV2PresetName.value = preset.name
@@ -4867,6 +5000,12 @@ function applyStillSettings(still: LibraryStill) {
}
if (mode === 'compose' && settings.task) v2Task.value = settings.task
if (settings.seed != null) seedInput.value = String(Math.round(settings.seed))
if (settings.loraStack?.length || settings.loraName) {
imageV2LoraStack.value = filterLoraStackForImageEngine(
filterLoraStackForStudio(readLoraStack(settings), ltxEnabled.value),
v2Engine.value
)
}
return
}
studioMode.value = 'edit'
@@ -5487,6 +5626,13 @@ function setV2Engine(engine: ImageV2Engine) {
v2ConsistencyModel.value = 0
v2ConsistencyClip.value = 0
v2Turbo.value = false
const dropped = imageV2LoraStack.value.filter(item => isKleinImageLora(item.name))
if (dropped.length) {
imageV2LoraStack.value = filterLoraStackForImageEngine(imageV2LoraStack.value, 'krea')
toast(dropped.length === 1
? kleinLoraOnKreaMessage(dropped[0].name)
: 'Flux LoRAs were removed. They cannot be used with Krea.')
}
if (v2Mode.value === 'edit') v2Denoise.value = IMAGE_V2_KREA_EDIT_DENOISE
if (v2Mode.value === 'compose') v2Denoise.value = IMAGE_V2_KREA_COMPOSE_DENOISE
return
@@ -5625,6 +5771,18 @@ async function editImageV2() {
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
body.append('name', clipName.value.trim())
const v2Stack = filterLoraStackForImageEngine(
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
v2Engine.value
)
if (v2Engine.value === 'krea') {
const klein = imageV2LoraStack.value.find(item => isKleinImageLora(item.name))
if (klein) {
toast(kleinLoraOnKreaMessage(klein.name))
return
}
}
if (v2Stack.length) body.append('loraStack', JSON.stringify(v2Stack))
const downloadName = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-v2')
const started = await $fetch<{
jobId: string