Add a Director section to save and reload multi-shot video scripts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 & 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
@@ -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
|
||||
|
||||
@@ -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">
|
||||
Studio
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user