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
+80
View File
@@ -0,0 +1,80 @@
<template>
<div class="flex min-w-0 flex-wrap items-end gap-2">
<label class="min-w-[10rem] flex-1 text-xs">
<span class="mb-1 block text-zinc-500">Saved script</span>
<select
:value="selectedId"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-2 py-1 text-xs outline-none ring-amber-300/40 focus:ring-2"
:disabled="disabled"
@change="onPick"
>
<option value="">Load…</option>
<option
v-for="script in scripts"
:key="script.id"
:value="script.id"
>{{ script.name }}</option>
</select>
</label>
<label class="w-36 text-xs">
<span class="mb-1 block text-zinc-500">Name</span>
<input
:value="name"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-2 py-1 text-xs outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Save as…"
maxlength="80"
:disabled="disabled"
@input="emit('update:name', ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="emit('save')"
>
</label>
<button
type="button"
class="rounded-xl border border-white/10 px-2 py-1 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:opacity-50"
:disabled="disabled || !name.trim()"
@click="emit('save')"
>
Save
</button>
<button
v-if="selectedId"
type="button"
class="rounded-xl border border-white/10 px-2 py-1 text-xs text-zinc-500 hover:border-red-400/40 hover:text-red-300 disabled:opacity-50"
:disabled="disabled"
@click="emit('remove')"
>
Delete
</button>
<NuxtLink
to="/director"
class="rounded-xl border border-white/10 px-2 py-1 text-xs text-zinc-400 hover:border-amber-300/40 hover:text-white"
>
Director
</NuxtLink>
</div>
</template>
<script setup lang="ts">
import type { DirectorScript } from '~/utils/directorScripts'
defineProps<{
scripts: DirectorScript[]
selectedId: string
name: string
disabled?: boolean
}>()
const emit = defineEmits<{
load: [id: string]
save: []
remove: []
'update:name': [value: string]
'update:selectedId': [value: string]
}>()
function onPick(event: Event) {
const id = (event.target as HTMLSelectElement).value
emit('update:selectedId', id)
if (id) emit('load', id)
}
</script>
+308
View File
@@ -0,0 +1,308 @@
<template>
<div class="min-h-dvh min-w-0">
<header class="sticky top-0 z-20 border-b border-white/5 bg-zinc-950/70 backdrop-blur-xl">
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6">
<div class="flex items-center gap-3">
<NuxtLink to="/" class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</NuxtLink>
<div>
<h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1>
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">Director</p>
</div>
</div>
<div class="flex items-center gap-3 text-sm">
<NuxtLink to="/" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">
Studio
</NuxtLink>
<NuxtLink
to="/director"
class="rounded-full border border-amber-300/70 bg-amber-400/10 px-3 py-1 text-amber-100"
>
Director
</NuxtLink>
<NuxtLink to="/queue" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">
Queue
</NuxtLink>
<a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a>
</div>
</div>
</header>
<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(18rem,22rem)]">
<section class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="font-display text-2xl font-bold">Director</h2>
<p class="mt-1 max-w-2xl text-sm text-zinc-400">
Write and save multi-shot scripts. Load one later from this list or from Video — it fills the prompt and splits into shots the same way a paste does.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white"
@click="startNew"
>
New
</button>
<button
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busy || !name.trim() || !text.trim()"
@click="save"
>
{{ busy === 'save' ? 'Saving…' : selectedId ? 'Save' : 'Save script' }}
</button>
<button
type="button"
class="rounded-full bg-amber-400 px-3 py-1.5 text-xs font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busy || !text.trim()"
@click="openInVideo"
>
Open in Video
</button>
</div>
</div>
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
<p v-else-if="notice" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">{{ notice }}</p>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Name</span>
<input
v-model="name"
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"
maxlength="80"
placeholder="e.g. Forest walk"
>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Shot script</span>
<p class="mb-2 text-xs text-zinc-500">
Same format as Video → Shot 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. Each shot uses
<span class="font-medium text-zinc-300">[SHOT CONFIGURATION]</span>,
<span class="font-medium text-zinc-300">[SUBJECT DIRECTION &amp; ACTION]</span>,
<span class="font-medium text-zinc-300">[AUDIO CUES]</span>.
</p>
<textarea
v-model="text"
rows="18"
class="max-h-[min(40rem,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"
:placeholder="placeholder"
/>
</label>
<div class="space-y-2">
<p class="text-xs text-zinc-500">
<span v-if="parsedShots.length">{{ parsedShots.length }} shot{{ parsedShots.length === 1 ? '' : 's' }} parsed</span>
<span v-else>Shots appear here once the script has a first beat.</span>
</p>
<div
v-for="(shot, index) in parsedShots"
:key="shot.n + '-' + index"
class="rounded-xl border border-white/10 bg-zinc-950/50 px-3 py-2"
>
<p class="text-xs font-medium text-amber-100">
{{ index === 0 ? 'Initial' : `Extension ${index}` }}
<span class="font-normal text-zinc-500"> · shot {{ shot.n }}</span>
</p>
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ shot.prompt }}</p>
</div>
</div>
</section>
<aside class="min-w-0 space-y-4">
<section class="glass rounded-3xl p-5 sm:p-6">
<h3 class="font-display text-lg font-bold">Saved scripts</h3>
<p class="mt-1 text-xs text-zinc-500">These are the video products you can load in Video.</p>
<p v-if="!scripts.length" class="mt-4 rounded-2xl border border-white/5 bg-zinc-950/40 px-4 py-6 text-center text-sm text-zinc-500">
Nothing saved yet.
</p>
<div v-else class="mt-4 space-y-2">
<button
v-for="script in scripts"
:key="script.id"
type="button"
class="w-full rounded-2xl border p-3 text-left transition"
:class="script.id === selectedId
? 'border-amber-300/30 bg-amber-400/5'
: 'border-white/5 bg-zinc-950/40 hover:border-white/15'"
@click="select(script)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-zinc-100">{{ script.name }}</p>
<p class="mt-0.5 text-[11px] text-zinc-500">{{ shotCount(script.text) }} shot{{ shotCount(script.text) === 1 ? '' : 's' }}</p>
</div>
<button
type="button"
class="shrink-0 text-xs text-zinc-500 hover:text-red-300"
@click.stop="remove(script)"
>
Delete
</button>
</div>
</button>
</div>
</section>
</aside>
</main>
</div>
</template>
<script setup lang="ts">
import type { DirectorScript } from '~/utils/directorScripts'
import { parseShotScript } from '~/utils/parseShots'
const DIRECTOR_IMPORT_KEY = 'aigen-director-import'
const placeholder = `shot 1
[SHOT CONFIGURATION]
- Shot Type: Medium-wide, 50mm, eye-level
- Camera Movement: Locked-off
- Lighting: From the still
- Environment/Scenery: From the still
[SUBJECT DIRECTION & ACTION]
The one new action for this clip.
[AUDIO CUES]
Ambient only.
shot 2
[SHOT CONFIGURATION]
- Shot Type: Medium-wide, 50mm, eye-level
- Camera Movement: Locked-off
- Lighting: Same as now
- Environment/Scenery: Same as now
[SUBJECT DIRECTION & ACTION]
The next beat. Same clothes as now.
[AUDIO CUES]
Ambient only.`
const instanceName = ref(String(useRuntimeConfig().public.instanceName || 'AIGen'))
const authEnabled = ref(false)
const scripts = ref<DirectorScript[]>([])
const selectedId = ref('')
const name = ref('')
const text = ref('')
const busy = ref('')
const error = ref('')
const notice = ref('')
const parsedShots = computed(() => parseShotScript(text.value))
function shotCount(src: string) {
return parseShotScript(src).length
}
function startNew() {
selectedId.value = ''
name.value = ''
text.value = ''
error.value = ''
notice.value = ''
}
function select(script: DirectorScript) {
selectedId.value = script.id
name.value = script.name
text.value = script.text
error.value = ''
notice.value = ''
}
async function loadScripts() {
const listed = await $fetch<{ scripts?: DirectorScript[] }>('/api/director-scripts')
scripts.value = listed.scripts || []
}
async function save() {
const nextName = name.value.trim()
const nextText = text.value.trim()
if (!nextName || !nextText) return
const existingByName = scripts.value.find(item => item.name.toLowerCase() === nextName.toLowerCase())
const updateExisting = Boolean(selectedId.value) && (!existingByName || existingByName.id === selectedId.value)
if (existingByName && existingByName.id !== selectedId.value && !window.confirm(`Overwrite script “${existingByName.name}”?`)) return
busy.value = 'save'
error.value = ''
notice.value = ''
try {
if (updateExisting && selectedId.value) {
const saved = await $fetch<{ script: DirectorScript; scripts: DirectorScript[] }>(`/api/director-scripts/${selectedId.value}`, {
method: 'PATCH',
body: { name: nextName, text: text.value }
})
scripts.value = saved.scripts
select(saved.script)
notice.value = `Saved “${saved.script.name}”.`
} else {
const saved = await $fetch<{ script: DirectorScript; scripts: DirectorScript[] }>('/api/director-scripts', {
method: 'POST',
body: { name: nextName, text: text.value, overwrite: Boolean(existingByName) }
})
scripts.value = saved.scripts
select(saved.script)
notice.value = existingByName ? `Overwrote “${saved.script.name}”.` : `Saved “${saved.script.name}”.`
}
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not save that script'
} finally {
busy.value = ''
}
}
async function remove(script: DirectorScript) {
if (!window.confirm(`Delete script “${script.name}”?`)) return
busy.value = 'delete'
error.value = ''
notice.value = ''
try {
const saved = await $fetch<{ scripts: DirectorScript[] }>(`/api/director-scripts/${script.id}`, { method: 'DELETE' })
scripts.value = saved.scripts
if (selectedId.value === script.id) startNew()
notice.value = `Deleted “${script.name}”.`
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not delete that script'
} finally {
busy.value = ''
}
}
async function openInVideo() {
if (!text.value.trim()) return
if (name.value.trim() && (!selectedId.value || dirty())) {
await save()
if (error.value) return
}
if (selectedId.value) {
await navigateTo({ path: '/', query: { script: selectedId.value } })
return
}
try {
sessionStorage.setItem(DIRECTOR_IMPORT_KEY, text.value)
} catch { /* ignore */ }
await navigateTo('/')
}
function dirty() {
const current = scripts.value.find(item => item.id === selectedId.value)
if (!current) return Boolean(text.value.trim())
return current.name !== name.value.trim() || current.text !== text.value
}
onMounted(async () => {
const me = await $fetch<{ authEnabled?: boolean; instanceName?: string }>('/api/auth/me').catch(() => ({}))
authEnabled.value = Boolean(me.authEnabled)
instanceName.value = me.instanceName || instanceName.value
try {
await loadScripts()
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.message || 'Could not load saved scripts'
}
})
</script>
+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"> <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 Settings
</button> </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 <NuxtLink
to="/queue" to="/queue"
class="rounded-full border px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" class="rounded-full border px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"
@@ -241,8 +247,19 @@
</label> </label>
</div> </div>
</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"> <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> <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>
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500"> <p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
@@ -733,6 +750,16 @@
</button> </button>
</div> </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> <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 v-if="v2Mode === 'generate'" class="space-y-3 rounded-2xl border border-white/10 bg-zinc-950/40 p-3">
<div> <div>
<p class="text-xs text-zinc-400">Size · near 1 MP</p> <p class="text-xs text-zinc-400">Size · near 1 MP</p>
@@ -1929,6 +1956,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend' import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
import { parseShotScript } from '~/utils/parseShots' import { parseShotScript } from '~/utils/parseShots'
import type { DirectorScript } from '~/utils/directorScripts'
import { import {
composeShotPrompt, composeShotPrompt,
extractGlobalLocks, extractGlobalLocks,
@@ -1942,7 +1970,12 @@ import {
type PermanenceRef type PermanenceRef
} from '~/utils/globalLocks' } from '~/utils/globalLocks'
import { import {
filterLoraStackForImageEngine,
filterLoraStackForStudio, filterLoraStackForStudio,
filterLorasForImageEngine,
isKleinImageLora,
kleinLoraOnKreaMessage,
loraDisplayName,
loraStacksEqual, loraStacksEqual,
normalizeLoraStack, normalizeLoraStack,
persistLoraFields, persistLoraFields,
@@ -2024,6 +2057,8 @@ import {
} from '~/utils/videoModels' } from '~/utils/videoModels'
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const route = useRoute()
const router = useRouter()
type AspectId = 'auto' | '16:9' | '9:16' | '1:1' type AspectId = 'auto' | '16:9' | '9:16' | '1:1'
@@ -2169,6 +2204,10 @@ const dragging = ref(false)
const prompt = ref('') const prompt = ref('')
const promptDrawerOpen = ref(false) const promptDrawerOpen = ref(false)
const shotScriptMode = 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 recommendPresetId = ref('enhance')
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean }>>([ const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean }>>([
{ id: 'enhance', name: 'Enhance', builtin: true }, { id: 'enhance', name: 'Enhance', builtin: true },
@@ -2194,6 +2233,7 @@ const imageHeight = ref(0)
const turbo = ref(true) const turbo = ref(true)
const videoLoraStack = ref<LoraStackItem[]>([]) const videoLoraStack = ref<LoraStackItem[]>([])
const imageLoraStack = ref<LoraStackItem[]>([]) const imageLoraStack = ref<LoraStackItem[]>([])
const imageV2LoraStack = ref<LoraStackItem[]>([])
const videoLoras = ref<string[]>([]) const videoLoras = ref<string[]>([])
const imageLoras = ref<string[]>([]) const imageLoras = ref<string[]>([])
const shotLoraStacks = ref<Record<number, LoraStackItem[]>>({}) 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)) ...extensionQueue.value.flatMap(item => (item.loraStack || []).map(entry => entry.name))
])) ]))
const imageLoraOptions = computed(() => mergeLoraOptions(imageLoras.value, imageLoraStack.value.map(item => item.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 useIdentityRefs = ref(false)
const identityRefs = ref<(File | null)[]>([null, null, null, null]) const identityRefs = ref<(File | null)[]>([null, null, null, null])
const identityPreviews = ref(['', '', '', '']) const identityPreviews = ref(['', '', '', ''])
@@ -3233,6 +3277,7 @@ onMounted(async () => {
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true' useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
videoLoraStack.value = readStoredLoraStack('aigen-video-lora') videoLoraStack.value = readStoredLoraStack('aigen-video-lora')
imageLoraStack.value = readStoredLoraStack('aigen-image-lora') imageLoraStack.value = readStoredLoraStack('aigen-image-lora')
imageV2LoraStack.value = readStoredLoraStack('aigen-image-v2-lora')
globalLocks.value = localStorage.getItem(LOCKS_STORE) || '' globalLocks.value = localStorage.getItem(LOCKS_STORE) || ''
try { try {
const savedRefs = JSON.parse(localStorage.getItem(REFS_STORE) || '') const savedRefs = JSON.parse(localStorage.getItem(REFS_STORE) || '')
@@ -3253,6 +3298,7 @@ onMounted(async () => {
applyComfyHealth(health) applyComfyHealth(health)
void pollLoras() void pollLoras()
void loadGenerationPresets() void loadGenerationPresets()
await loadDirectorScripts().catch(() => null)
userName.value = me.user?.name || me.user?.email || '' userName.value = me.user?.name || me.user?.email || ''
authEnabled.value = Boolean(me.authEnabled) authEnabled.value = Boolean(me.authEnabled)
authMode.value = me.authMode || '' authMode.value = me.authMode || ''
@@ -3270,6 +3316,7 @@ onMounted(async () => {
await refreshStudioQueue() await refreshStudioQueue()
await refreshLiveQueue() await refreshLiveQueue()
await loadPromptPresets() await loadPromptPresets()
applyIncomingDirectorScript()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -3405,6 +3452,10 @@ watch(imageLoraStack, (value) => {
try { localStorage.setItem('aigen-image-lora', JSON.stringify(value)) } catch { /* ignore */ } try { localStorage.setItem('aigen-image-lora', JSON.stringify(value)) } catch { /* ignore */ }
}, { deep: true }) }, { deep: true })
watch(imageV2LoraStack, (value) => {
try { localStorage.setItem('aigen-image-v2-lora', JSON.stringify(value)) } catch { /* ignore */ }
}, { deep: true })
function clampDuration(value: number) { function clampDuration(value: number) {
if (!Number.isFinite(value)) return 5 if (!Number.isFinite(value)) return 5
return Math.min(120, Math.max(0.5, Math.round(value * 10) / 10)) 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' 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() { async function loadGenerationPresets() {
const [video, image, imagev2] = await Promise.all([ const [video, image, imagev2] = await Promise.all([
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'video' } }).catch(() => ({ presets: [] })), $fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'video' } }).catch(() => ({ presets: [] })),
@@ -3502,7 +3631,10 @@ function currentPresetSnapshot() {
if (studioMode.value === 'editv2') { if (studioMode.value === 'editv2') {
return { return {
kind: 'imagev2' as const, kind: 'imagev2' as const,
loraStack: [], loraStack: filterLoraStackForImageEngine(
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
v2Engine.value
),
settings: { settings: {
mode: v2Mode.value, mode: v2Mode.value,
engine: v2Engine.value, engine: v2Engine.value,
@@ -3575,6 +3707,7 @@ function applyGenerationPreset(preset: GenerationPreset) {
v2Width.value = size.width v2Width.value = size.width
v2Height.value = size.height v2Height.value = size.height
} }
imageV2LoraStack.value = filterLoraStackForImageEngine(stack, v2Engine.value)
void ensureRefineCanvas() void ensureRefineCanvas()
imageV2PresetId.value = preset.id imageV2PresetId.value = preset.id
imageV2PresetName.value = preset.name imageV2PresetName.value = preset.name
@@ -4867,6 +5000,12 @@ function applyStillSettings(still: LibraryStill) {
} }
if (mode === 'compose' && settings.task) v2Task.value = settings.task if (mode === 'compose' && settings.task) v2Task.value = settings.task
if (settings.seed != null) seedInput.value = String(Math.round(settings.seed)) 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 return
} }
studioMode.value = 'edit' studioMode.value = 'edit'
@@ -5487,6 +5626,13 @@ function setV2Engine(engine: ImageV2Engine) {
v2ConsistencyModel.value = 0 v2ConsistencyModel.value = 0
v2ConsistencyClip.value = 0 v2ConsistencyClip.value = 0
v2Turbo.value = false 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 === 'edit') v2Denoise.value = IMAGE_V2_KREA_EDIT_DENOISE
if (v2Mode.value === 'compose') v2Denoise.value = IMAGE_V2_KREA_COMPOSE_DENOISE if (v2Mode.value === 'compose') v2Denoise.value = IMAGE_V2_KREA_COMPOSE_DENOISE
return return
@@ -5625,6 +5771,18 @@ async function editImageV2() {
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value)) body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value)) body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
body.append('name', clipName.value.trim()) 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 downloadName = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-v2')
const started = await $fetch<{ const started = await $fetch<{
jobId: string jobId: string
+3
View File
@@ -13,6 +13,9 @@
<NuxtLink to="/" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white"> <NuxtLink to="/" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white">
Studio Studio
</NuxtLink> </NuxtLink>
<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>
<a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a> <a v-if="authEnabled" href="/api/auth/logout" class="text-zinc-400 hover:text-white">Sign out</a>
</div> </div>
</div> </div>
+8
View File
@@ -0,0 +1,8 @@
import { listDirectorScripts } from '~/server/utils/directorScripts'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
return {
scripts: listDirectorScripts(owner)
}
})
+19
View File
@@ -0,0 +1,19 @@
import { listDirectorScripts, saveDirectorScript } from '~/server/utils/directorScripts'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const body = await readBody<{
name?: string
text?: string
overwrite?: boolean
}>(event).catch(() => ({}))
const script = await saveDirectorScript(owner, {
name: body?.name || '',
text: body?.text || '',
overwrite: body?.overwrite === true
})
return {
script,
scripts: listDirectorScripts(owner)
}
})
@@ -0,0 +1,11 @@
import { deleteDirectorScript, listDirectorScripts } from '~/server/utils/directorScripts'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
await deleteDirectorScript(owner, id)
return {
ok: true,
scripts: listDirectorScripts(owner)
}
})
+9
View File
@@ -0,0 +1,9 @@
import { getDirectorScript } from '~/server/utils/directorScripts'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
return {
script: getDirectorScript(owner, id)
}
})
+15
View File
@@ -0,0 +1,15 @@
import { listDirectorScripts, updateDirectorScript } from '~/server/utils/directorScripts'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ name?: string; text?: string }>(event).catch(() => ({}))
const script = await updateDirectorScript(owner, id, {
name: body?.name,
text: body?.text
})
return {
script,
scripts: listDirectorScripts(owner)
}
})
+148
View File
@@ -0,0 +1,148 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { ownerRoot } from '~/server/utils/library'
import {
normalizeDirectorScriptName,
normalizeDirectorScriptText,
type DirectorScript
} from '~/utils/directorScripts'
const writeChains = new Map<string, Promise<unknown>>()
function scriptsPath(owner: string) {
return join(ownerRoot(owner), 'director-scripts.json')
}
function ensureOwner(owner: string) {
mkdirSync(ownerRoot(owner), { recursive: true })
}
function normalizeStored(raw: unknown): DirectorScript | null {
if (!raw || typeof raw !== 'object') return null
const rec = raw as Record<string, unknown>
const name = normalizeDirectorScriptName(rec.name)
const text = normalizeDirectorScriptText(rec.text)
if (!name || !text.trim()) return null
const createdAt = Number(rec.createdAt) || Date.now()
return {
id: String(rec.id || '').trim() || crypto.randomUUID(),
name,
text,
createdAt,
updatedAt: Number(rec.updatedAt) || createdAt
}
}
function readScripts(owner: string): DirectorScript[] {
ensureOwner(owner)
const path = scriptsPath(owner)
if (!existsSync(path)) return []
try {
const parsed = JSON.parse(readFileSync(path, 'utf8'))
return Array.isArray(parsed)
? parsed.map(normalizeStored).filter((item): item is DirectorScript => Boolean(item))
: []
} catch {
return []
}
}
function writeScripts(owner: string, scripts: DirectorScript[]) {
ensureOwner(owner)
const path = scriptsPath(owner)
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(scripts, null, 2))
renameSync(tmp, path)
}
function mutate<T>(owner: string, fn: (scripts: DirectorScript[]) => T): Promise<T> {
const prev = writeChains.get(owner) || Promise.resolve()
const run = prev.then(() => {
const scripts = readScripts(owner)
const result = fn(scripts)
writeScripts(owner, scripts)
return result
})
writeChains.set(owner, run.then(() => undefined, () => undefined))
return run
}
export function listDirectorScripts(owner: string) {
return readScripts(owner).sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name))
}
export function getDirectorScript(owner: string, id: string) {
const wanted = String(id || '').trim()
const script = readScripts(owner).find(item => item.id === wanted)
if (!script) throw createError({ statusCode: 404, statusMessage: 'Script not found' })
return script
}
export async function saveDirectorScript(owner: string, input: {
name: string
text: string
overwrite?: boolean
}) {
const name = normalizeDirectorScriptName(input.name)
const text = normalizeDirectorScriptText(input.text)
if (!name) throw createError({ statusCode: 400, statusMessage: 'A script name is required' })
if (!text.trim()) throw createError({ statusCode: 400, statusMessage: 'A shot script is required' })
const now = Date.now()
return mutate(owner, (scripts) => {
const existing = scripts.find(item => item.name.toLowerCase() === name.toLowerCase())
if (existing && !input.overwrite) {
throw createError({ statusCode: 409, statusMessage: `Overwrite script “${existing.name}”?` })
}
if (existing) {
existing.text = text
existing.updatedAt = now
return existing
}
const created: DirectorScript = {
id: crypto.randomUUID(),
name,
text,
createdAt: now,
updatedAt: now
}
scripts.unshift(created)
return created
})
}
export async function updateDirectorScript(owner: string, id: string, input: {
name?: string
text?: string
}) {
const wanted = String(id || '').trim()
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing script id' })
return mutate(owner, (scripts) => {
const existing = scripts.find(item => item.id === wanted)
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Script not found' })
if (input.name !== undefined) {
const name = normalizeDirectorScriptName(input.name)
if (!name) throw createError({ statusCode: 400, statusMessage: 'A script name is required' })
const clash = scripts.find(item => item.id !== wanted && item.name.toLowerCase() === name.toLowerCase())
if (clash) throw createError({ statusCode: 409, statusMessage: `A script named “${clash.name}” already exists` })
existing.name = name
}
if (input.text !== undefined) {
const text = normalizeDirectorScriptText(input.text)
if (!text.trim()) throw createError({ statusCode: 400, statusMessage: 'A shot script is required' })
existing.text = text
}
existing.updatedAt = Date.now()
return existing
})
}
export async function deleteDirectorScript(owner: string, id: string) {
const wanted = String(id || '').trim()
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing script id' })
return mutate(owner, (scripts) => {
const index = scripts.findIndex(item => item.id === wanted)
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Script not found' })
const [removed] = scripts.splice(index, 1)
return removed
})
}
+18
View File
@@ -0,0 +1,18 @@
export type DirectorScript = {
id: string
name: string
text: string
createdAt: number
updatedAt: number
}
export const DIRECTOR_SCRIPT_NAME_MAX = 80
export const DIRECTOR_SCRIPT_TEXT_MAX = 200_000
export function normalizeDirectorScriptName(raw: unknown) {
return String(raw || '').trim().replace(/\s+/g, ' ').slice(0, DIRECTOR_SCRIPT_NAME_MAX)
}
export function normalizeDirectorScriptText(raw: unknown) {
return String(raw || '').replace(/\r\n/g, '\n').slice(0, DIRECTOR_SCRIPT_TEXT_MAX)
}