Add Prompt Builder sequence section order.

Copied scene text and the scene editor follow a persisted sectionOrder, with legacy saves keeping the default style/settings/set/characters/prompt order.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-10-04 07:07:43 -05:00
co-authored by Cursor
parent b6eef6a511
commit 45e97cc1c3
6 changed files with 1113 additions and 878 deletions
+100 -7
View File
@@ -44,6 +44,45 @@
<p class="mt-1 text-[11px] text-zinc-500">Save key only — never copied into the prompt text.</p> <p class="mt-1 text-[11px] text-zinc-500">Save key only — never copied into the prompt text.</p>
</label> </label>
<div class="space-y-3">
<div>
<span class="text-sm font-medium text-zinc-300">Prompt order</span>
<p class="text-[11px] text-zinc-500">Drag to reorder, or use the arrows. Copied text and the scene editor follow this order.</p>
</div>
<div class="space-y-2">
<div
v-for="(key, index) in sectionOrder"
:key="key"
class="flex items-center gap-2 rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2"
draggable="true"
@dragstart="onSectionDragStart(index, $event)"
@dragover.prevent="onSectionDragOver($event)"
@drop.prevent="onSectionDrop(index)"
>
<span class="cursor-grab select-none text-zinc-500 active:cursor-grabbing" title="Drag to reorder">⠿</span>
<span class="min-w-0 flex-1 text-sm text-zinc-200">{{ sectionLabel(key) }}</span>
<button
type="button"
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Move up"
:disabled="index === 0"
@click="moveSection(index, -1)"
>
↑
</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Move down"
:disabled="index >= sectionOrder.length - 1"
@click="moveSection(index, 1)"
>
↓
</button>
</div>
</div>
</div>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div> <div>
@@ -271,7 +310,8 @@
> >
</label> </label>
<div class="space-y-1">␍ <template v-for="sectionKey in sectionOrder" :key="'scene-section-' + sectionKey">
<div v-if="sectionKey === 'style'" class="space-y-1">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Style</span> <span class="text-sm font-medium text-zinc-300">Style</span>
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.style = ''">Clear</button> <button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.style = ''">Clear</button>
@@ -284,7 +324,7 @@
/> />
</div> </div>
<div class="space-y-1">␍ <div v-else-if="sectionKey === 'settings'" class="space-y-1">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Settings</span> <span class="text-sm font-medium text-zinc-300">Settings</span>
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.settings = ''">Clear</button> <button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.settings = ''">Clear</button>
@@ -297,7 +337,7 @@
/> />
</div> </div>
<div v-if="sets.length" class="space-y-3">␍ <div v-else-if="sectionKey === 'set' && sets.length" class="space-y-3">
<div> <div>
<span class="text-sm font-medium text-zinc-300">Sets in this scene</span> <span class="text-sm font-medium text-zinc-300">Sets in this scene</span>
<p class="text-[11px] text-zinc-500">Toggle which set blocks appear. Pick a framing for each on set.</p> <p class="text-[11px] text-zinc-500">Toggle which set blocks appear. Pick a framing for each on set.</p>
@@ -332,7 +372,7 @@
</div> </div>
</div> </div>
<div class="space-y-3">␍ <div v-else-if="sectionKey === 'characters'" class="space-y-3">
<div> <div>
<span class="text-sm font-medium text-zinc-300">In this scene</span> <span class="text-sm font-medium text-zinc-300">In this scene</span>
<p class="text-[11px] text-zinc-500">Toggle who appears. Pick an outfit for each on character.</p> <p class="text-[11px] text-zinc-500">Toggle who appears. Pick an outfit for each on character.</p>
@@ -367,7 +407,7 @@
</div> </div>
</div> </div>
<div class="space-y-1">␍ <div v-else-if="sectionKey === 'prompt'" class="space-y-1">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Prompt</span> <span class="text-sm font-medium text-zinc-300">Prompt</span>
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.prompt = ''">Clear</button> <button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.prompt = ''">Clear</button>
@@ -379,6 +419,7 @@
placeholder="What happens in this image" placeholder="What happens in this image"
/> />
</div> </div>
</template>
</div> </div>
<section class="rounded-2xl border border-white/10 bg-zinc-950/30"> <section class="rounded-2xl border border-white/10 bg-zinc-950/30">
@@ -467,9 +508,11 @@ import type {
PromptSequence, PromptSequence,
PromptSequenceCharacter, PromptSequenceCharacter,
PromptSequenceScene, PromptSequenceScene,
PromptSequenceSectionKey,
PromptSequenceSet PromptSequenceSet
} from '~/utils/promptSequences' } from '~/utils/promptSequences'
import { import {
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
PROMPT_SEQUENCE_FRAMING_SUGGESTIONS, PROMPT_SEQUENCE_FRAMING_SUGGESTIONS,
assembleSceneText, assembleSceneText,
assembleSequenceText, assembleSequenceText,
@@ -481,14 +524,24 @@ import {
emptySet, emptySet,
ensureSceneStatesForCharacters, ensureSceneStatesForCharacters,
ensureSceneStatesForSets, ensureSceneStatesForSets,
normalizePromptSequence␍ normalizePromptSequence,
normalizeSectionOrder
} from '~/utils/promptSequences' } from '~/utils/promptSequences'
useHead({ title: 'Prompt Builder' }) useHead({ title: 'Prompt Builder' })
const SECTION_LABELS: Record<PromptSequenceSectionKey, string> = {
style: 'Style',
settings: 'Settings',
set: 'Set',
characters: 'Characters',
prompt: 'Prompt'
}
const sequences = ref<PromptSequence[]>([]) const sequences = ref<PromptSequence[]>([])
const selectedId = ref('') const selectedId = ref('')
const name = ref('') const name = ref('')
const sectionOrder = ref<PromptSequenceSectionKey[]>([...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
const characters = ref<PromptSequenceCharacter[]>([emptyCharacter()]) const characters = ref<PromptSequenceCharacter[]>([emptyCharacter()])
const sets = ref<PromptSequenceSet[]>([]) const sets = ref<PromptSequenceSet[]>([])
const scenes = ref<PromptSequenceScene[]>([emptyScene(1, characters.value, sets.value)]) const scenes = ref<PromptSequenceScene[]>([emptyScene(1, characters.value, sets.value)])
@@ -501,18 +554,55 @@ const copied = ref(false)
const copiedScene = ref(false) const copiedScene = ref(false)
let copyTimer: ReturnType<typeof setTimeout> | null = null let copyTimer: ReturnType<typeof setTimeout> | null = null
let copySceneTimer: ReturnType<typeof setTimeout> | null = null let copySceneTimer: ReturnType<typeof setTimeout> | null = null
let dragSectionIndex: number | null = null
const activeScene = computed(() => scenes.value[activeIndex.value] || null) const activeScene = computed(() => scenes.value[activeIndex.value] || null)
const previewText = computed(() => assembleSequenceText({ const previewText = computed(() => assembleSequenceText({
sectionOrder: sectionOrder.value,
characters: characters.value, characters: characters.value,
sets: sets.value, sets: sets.value,
scenes: scenes.value scenes: scenes.value
})) }))
const activeSceneText = computed(() => { const activeSceneText = computed(() => {
if (!activeScene.value) return '' if (!activeScene.value) return ''
return assembleSceneText(activeScene.value, characters.value, sets.value)␍ return assembleSceneText(activeScene.value, characters.value, sets.value, sectionOrder.value)
}) })
function sectionLabel(key: PromptSequenceSectionKey) {
return SECTION_LABELS[key] || key
}
function moveSection(index: number, delta: number) {
const next = index + delta
if (next < 0 || next >= sectionOrder.value.length) return
const copy = [...sectionOrder.value]
const [item] = copy.splice(index, 1)
copy.splice(next, 0, item)
sectionOrder.value = copy
}
function onSectionDragStart(index: number, event: DragEvent) {
dragSectionIndex = index
event.dataTransfer?.setData('text/plain', String(index))
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
}
function onSectionDragOver(event: DragEvent) {
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
function onSectionDrop(index: number) {
if (dragSectionIndex == null || dragSectionIndex === index) {
dragSectionIndex = null
return
}
const copy = [...sectionOrder.value]
const [item] = copy.splice(dragSectionIndex, 1)
copy.splice(index, 0, item)
sectionOrder.value = copy
dragSectionIndex = null
}
function framingLabelPlaceholder(index: number) { function framingLabelPlaceholder(index: number) {
const suggestion = PROMPT_SEQUENCE_FRAMING_SUGGESTIONS[index % PROMPT_SEQUENCE_FRAMING_SUGGESTIONS.length] const suggestion = PROMPT_SEQUENCE_FRAMING_SUGGESTIONS[index % PROMPT_SEQUENCE_FRAMING_SUGGESTIONS.length]
return suggestion || 'wide / medium / narrow' return suggestion || 'wide / medium / narrow'
@@ -521,6 +611,7 @@ function framingLabelPlaceholder(index: number) {
function startNew() { function startNew() {
selectedId.value = '' selectedId.value = ''
name.value = '' name.value = ''
sectionOrder.value = [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]
characters.value = [emptyCharacter()] characters.value = [emptyCharacter()]
sets.value = [] sets.value = []
scenes.value = [emptyScene(1, characters.value, sets.value)] scenes.value = [emptyScene(1, characters.value, sets.value)]
@@ -533,6 +624,7 @@ function select(sequence: PromptSequence) {
const normalized = normalizePromptSequence(sequence) || sequence const normalized = normalizePromptSequence(sequence) || sequence
selectedId.value = normalized.id selectedId.value = normalized.id
name.value = normalized.name name.value = normalized.name
sectionOrder.value = normalizeSectionOrder(normalized.sectionOrder)
characters.value = (normalized.characters?.length ? normalized.characters : [emptyCharacter()]).map(character => ({ characters.value = (normalized.characters?.length ? normalized.characters : [emptyCharacter()]).map(character => ({
...emptyCharacter(), ...emptyCharacter(),
...character, ...character,
@@ -781,6 +873,7 @@ async function save() {
notice.value = '' notice.value = ''
const payload = { const payload = {
name: nextName, name: nextName,
sectionOrder: sectionOrder.value,
characters: characters.value, characters: characters.value,
sets: sets.value, sets: sets.value,
scenes: scenes.value scenes: scenes.value
+2
View File
@@ -4,6 +4,7 @@ export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event) const { owner } = assertLibraryOwner(event)
const body = await readBody<{ const body = await readBody<{
name?: string name?: string
sectionOrder?: unknown
characters?: unknown characters?: unknown
sets?: unknown sets?: unknown
scenes?: unknown scenes?: unknown
@@ -11,6 +12,7 @@ export default defineEventHandler(async (event) => {
}>(event).catch(() => ({})) }>(event).catch(() => ({}))
const sequence = await savePromptSequence(owner, { const sequence = await savePromptSequence(owner, {
name: body?.name || '', name: body?.name || '',
sectionOrder: body?.sectionOrder,
characters: body?.characters, characters: body?.characters,
sets: body?.sets, sets: body?.sets,
scenes: body?.scenes, scenes: body?.scenes,
+8 -1
View File
@@ -3,9 +3,16 @@ import { listPromptSequences, updatePromptSequence } from '~/server/utils/prompt
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event) const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '') const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ name?: string; characters?: unknown; sets?: unknown; scenes?: unknown }>(event).catch(() => ({})) const body = await readBody<{
name?: string
sectionOrder?: unknown
characters?: unknown
sets?: unknown
scenes?: unknown
}>(event).catch(() => ({}))
const sequence = await updatePromptSequence(owner, id, { const sequence = await updatePromptSequence(owner, id, {
name: body?.name, name: body?.name,
sectionOrder: body?.sectionOrder,
characters: body?.characters, characters: body?.characters,
sets: body?.sets, sets: body?.sets,
scenes: body?.scenes scenes: body?.scenes
+15
View File
@@ -7,10 +7,13 @@ import {
normalizeCharacter, normalizeCharacter,
normalizeSet, normalizeSet,
normalizeScene, normalizeScene,
normalizeSectionOrder,
emptyCharacter, emptyCharacter,
emptyScene, emptyScene,
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
type PromptSequence, type PromptSequence,
type PromptSequenceCharacter, type PromptSequenceCharacter,
type PromptSequenceSectionKey,
type PromptSequenceSet, type PromptSequenceSet,
type PromptSequenceScene type PromptSequenceScene
} from '~/utils/promptSequences' } from '~/utils/promptSequences'
@@ -91,6 +94,7 @@ export function getPromptSequence(owner: string, id: string) {
export async function savePromptSequence(owner: string, input: { export async function savePromptSequence(owner: string, input: {
name: string name: string
sectionOrder?: unknown
characters?: unknown characters?: unknown
sets?: unknown sets?: unknown
scenes?: unknown scenes?: unknown
@@ -101,6 +105,9 @@ export async function savePromptSequence(owner: string, input: {
const characters = normalizeCharactersInput(input.characters) const characters = normalizeCharactersInput(input.characters)
const sets = normalizeSetsInput(input.sets) const sets = normalizeSetsInput(input.sets)
const scenes = normalizeScenesInput(input.scenes, characters, sets) const scenes = normalizeScenesInput(input.scenes, characters, sets)
const sectionOrder = input.sectionOrder !== undefined
? normalizeSectionOrder(input.sectionOrder)
: [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]
const now = Date.now() const now = Date.now()
return mutate(owner, (sequences) => { return mutate(owner, (sequences) => {
const existing = sequences.find(item => item.name.toLowerCase() === name.toLowerCase()) const existing = sequences.find(item => item.name.toLowerCase() === name.toLowerCase())
@@ -108,6 +115,7 @@ export async function savePromptSequence(owner: string, input: {
throw createError({ statusCode: 409, statusMessage: `Overwrite sequence “${existing.name}”?` }) throw createError({ statusCode: 409, statusMessage: `Overwrite sequence “${existing.name}”?` })
} }
if (existing) { if (existing) {
existing.sectionOrder = sectionOrder
existing.characters = characters existing.characters = characters
existing.sets = sets existing.sets = sets
existing.scenes = scenes existing.scenes = scenes
@@ -117,6 +125,7 @@ export async function savePromptSequence(owner: string, input: {
const created: PromptSequence = { const created: PromptSequence = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name, name,
sectionOrder,
characters, characters,
sets, sets,
scenes, scenes,
@@ -130,6 +139,7 @@ export async function savePromptSequence(owner: string, input: {
export async function updatePromptSequence(owner: string, id: string, input: { export async function updatePromptSequence(owner: string, id: string, input: {
name?: string name?: string
sectionOrder?: unknown
characters?: unknown characters?: unknown
sets?: unknown sets?: unknown
scenes?: unknown scenes?: unknown
@@ -146,6 +156,11 @@ export async function updatePromptSequence(owner: string, id: string, input: {
if (clash) throw createError({ statusCode: 409, statusMessage: `A sequence named “${clash.name}” already exists` }) if (clash) throw createError({ statusCode: 409, statusMessage: `A sequence named “${clash.name}” already exists` })
existing.name = name existing.name = name
} }
if (input.sectionOrder !== undefined) {
existing.sectionOrder = normalizeSectionOrder(input.sectionOrder)
} else if (!existing.sectionOrder?.length) {
existing.sectionOrder = [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER] as PromptSequenceSectionKey[]
}
if (input.characters !== undefined || input.sets !== undefined || input.scenes !== undefined) { if (input.characters !== undefined || input.sets !== undefined || input.scenes !== undefined) {
const characters = input.characters !== undefined const characters = input.characters !== undefined
? normalizeCharactersInput(input.characters) ? normalizeCharactersInput(input.characters)
+82
View File
@@ -26,6 +26,8 @@ const {
emptyFraming, emptyFraming,
cloneSceneForNext, cloneSceneForNext,
normalizePromptSequence, normalizePromptSequence,
normalizeSectionOrder,
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
ensureSceneStatesForCharacters ensureSceneStatesForCharacters
} = await import(pathToFileURL(tmp).href) } = await import(pathToFileURL(tmp).href)
unlinkSync(tmp) unlinkSync(tmp)
@@ -204,3 +206,83 @@ test('ensureSceneStatesForCharacters turns new character on only for current sce
assert.equal(scenes[0].characterStates.find(s => s.characterId === b.id).on, false) assert.equal(scenes[0].characterStates.find(s => s.characterId === b.id).on, false)
assert.equal(scenes[1].characterStates.find(s => s.characterId === b.id).on, true) assert.equal(scenes[1].characterStates.find(s => s.characterId === b.id).on, true)
}) })
test('sectionOrder settings then prompt then characters; empty style and set omitted', () => {
const ada = {
...emptyCharacter(),
id: 'c1',
name: 'Ada',
description: 'tall',
outfits: []
}
const set = {
...emptySet(),
id: 's1',
name: 'Courtyard',
framings: [{ ...emptyFraming('wide'), id: 'f1', text: 'open yard' }]
}
const scene = {
style: '',
settings: 'soft light',
setStates: [{ setId: 's1', on: true, framingId: null }],
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
prompt: 'looks up'
}
const order = ['settings', 'prompt', 'characters']
const text = assembleSceneText(scene, [ada], [set], order)
assert.equal(text, 'soft light\n\nlooks up\n\nAda: tall')
assert.ok(!text.includes('open yard'))
assert.ok(!text.includes('Courtyard'))
assert.ok(!text.includes('wide'))
})
test('legacy sequence without sectionOrder keeps default order and \\n\\n\\n join', () => {
const legacy = normalizePromptSequence({
id: 'seq1',
name: 'Old order',
characters: [{ id: 'c1', name: 'Ada', description: 'tall', outfits: [] }],
sets: [{
id: 's1',
name: 'Hall',
framings: [{ id: 'f1', label: 'wide', text: 'stone hall' }]
}],
scenes: [
{
id: 'sc1',
title: 'One',
style: 'oil',
settings: 'day',
setStates: [{ setId: 's1', on: true, framingId: 'f1' }],
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
prompt: 'walk'
},
{
id: 'sc2',
title: 'Two',
style: 'ink',
settings: '',
setStates: [{ setId: 's1', on: false, framingId: 'f1' }],
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
prompt: 'stop'
}
],
createdAt: 1,
updatedAt: 1
})
assert.deepEqual(legacy.sectionOrder, [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
assert.deepEqual(normalizeSectionOrder(undefined), [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
assert.deepEqual(normalizeSectionOrder(['prompt', 'prompt', 'bogus', 'settings']), [
'prompt',
'settings',
'style',
'set',
'characters'
])
const full = assembleSequenceText(legacy)
assert.equal(
full,
'oil\n\nday\n\nstone hall\n\nAda: tall\n\nwalk\n\n\nink\n\nAda: tall\n\nstop'
)
assert.ok(!full.includes('Hall'))
assert.ok(!full.includes('wide'))
})
+45 -9
View File
@@ -50,9 +50,18 @@ export type PromptSequenceScene = {
prompt: string prompt: string
} }
/** Scene prompt sections. Default copy order. */
export const PROMPT_SEQUENCE_SECTION_KEYS = ['style', 'settings', 'set', 'characters', 'prompt'] as const
export type PromptSequenceSectionKey = (typeof PROMPT_SEQUENCE_SECTION_KEYS)[number]
export const DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER: PromptSequenceSectionKey[] = [
...PROMPT_SEQUENCE_SECTION_KEYS
]
export type PromptSequence = { export type PromptSequence = {
id: string id: string
name: string name: string
/** Order of scene sections when assembling / in the editor. */
sectionOrder: PromptSequenceSectionKey[]
characters: PromptSequenceCharacter[] characters: PromptSequenceCharacter[]
sets: PromptSequenceSet[] sets: PromptSequenceSet[]
scenes: PromptSequenceScene[] scenes: PromptSequenceScene[]
@@ -60,6 +69,26 @@ export type PromptSequence = {
updatedAt: number updatedAt: number
} }
/** Valid unique keys in given order; missing keys append in default order. */
export function normalizeSectionOrder(raw: unknown): PromptSequenceSectionKey[] {
const allowed = new Set<string>(PROMPT_SEQUENCE_SECTION_KEYS)
const seen = new Set<PromptSequenceSectionKey>()
const ordered: PromptSequenceSectionKey[] = []
if (Array.isArray(raw)) {
for (const item of raw) {
const key = String(item || '').trim()
if (!allowed.has(key) || seen.has(key as PromptSequenceSectionKey)) continue
const typed = key as PromptSequenceSectionKey
seen.add(typed)
ordered.push(typed)
}
}
for (const key of DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER) {
if (!seen.has(key)) ordered.push(key)
}
return ordered
}
export const PROMPT_SEQUENCE_NAME_MAX = 80 export const PROMPT_SEQUENCE_NAME_MAX = 80
export const PROMPT_SEQUENCE_FIELD_MAX = 50_000 export const PROMPT_SEQUENCE_FIELD_MAX = 50_000
export const PROMPT_SEQUENCE_TITLE_MAX = 60 export const PROMPT_SEQUENCE_TITLE_MAX = 60
@@ -206,7 +235,8 @@ export function assembleSceneText(
characters?: Array<{ name?: string; description?: string }> characters?: Array<{ name?: string; description?: string }>
}, },
sequenceCharacters?: PromptSequenceCharacter[], sequenceCharacters?: PromptSequenceCharacter[],
sequenceSets?: PromptSequenceSet[] sequenceSets?: PromptSequenceSet[],
sectionOrder?: PromptSequenceSectionKey[] | unknown
) { ) {
const setBlock = sequenceSets?.length const setBlock = sequenceSets?.length
? assembleSetBlock(scene, sequenceSets) ? assembleSetBlock(scene, sequenceSets)
@@ -217,13 +247,15 @@ export function assembleSceneText(
.map(character => formatCharacterLine(character)) .map(character => formatCharacterLine(character))
.filter(Boolean) .filter(Boolean)
.join('\n') .join('\n')
const parts = [ const blocks: Record<PromptSequenceSectionKey, string> = {
String(scene.style || '').trim(), style: String(scene.style || '').trim(),
String(scene.settings || '').trim(), settings: String(scene.settings || '').trim(),
setBlock, set: setBlock,
characterBlock, characters: characterBlock,
String(scene.prompt || '').trim() prompt: String(scene.prompt || '').trim()
].filter(Boolean) }
const order = normalizeSectionOrder(sectionOrder)
const parts = order.map(key => blocks[key]).filter(Boolean)
return parts.join('\n\n') return parts.join('\n\n')
} }
@@ -233,6 +265,7 @@ export function assembleSceneText(
* Sequence name is never included. * Sequence name is never included.
*/ */
export function assembleSequenceText(sequence: { export function assembleSequenceText(sequence: {
sectionOrder?: PromptSequenceSectionKey[] | unknown
characters?: PromptSequenceCharacter[] characters?: PromptSequenceCharacter[]
sets?: PromptSequenceSet[] sets?: PromptSequenceSet[]
scenes?: Array<{ scenes?: Array<{
@@ -246,11 +279,13 @@ export function assembleSequenceText(sequence: {
}) { }) {
const characters = sequence.characters || [] const characters = sequence.characters || []
const sets = sequence.sets || [] const sets = sequence.sets || []
const sectionOrder = normalizeSectionOrder(sequence.sectionOrder)
return (sequence.scenes || []) return (sequence.scenes || [])
.map(scene => assembleSceneText( .map(scene => assembleSceneText(
scene, scene,
characters.length ? characters : undefined, characters.length ? characters : undefined,
sets.length ? sets : undefined sets.length ? sets : undefined,
sectionOrder
)) ))
.filter(Boolean) .filter(Boolean)
.join('\n\n\n') .join('\n\n\n')
@@ -478,6 +513,7 @@ export function normalizePromptSequence(raw: unknown): PromptSequence | null {
return { return {
id: String(rec.id || '').trim() || newId(), id: String(rec.id || '').trim() || newId(),
name, name,
sectionOrder: normalizeSectionOrder(rec.sectionOrder),
characters, characters,
sets, sets,
scenes, scenes,