Add Prompt Builder page for multi-scene prompt sequences.

Name, save, and load ordered scenes on the server; assemble copyable text with a Batch-compatible double blank line between scenes.
This commit is contained in:
Towsty
2026-10-02 07:15:00 -05:00
parent 5788d74001
commit 1bf97568bf
12 changed files with 866 additions and 1 deletions
+8 -1
View File
@@ -55,6 +55,13 @@
role="menuitem" role="menuitem"
@click="menuOpen = false" @click="menuOpen = false"
>Director</NuxtLink> >Director</NuxtLink>
<NuxtLink
to="/prompt-builder"
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
:class="current === 'prompt-builder' ? 'text-amber-100' : 'text-zinc-200'"
role="menuitem"
@click="menuOpen = false"
>Prompt Builder</NuxtLink>
<NuxtLink <NuxtLink
to="/music" to="/music"
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white" class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
@@ -131,7 +138,7 @@
<script setup lang="ts"> <script setup lang="ts">
withDefaults(defineProps<{ withDefaults(defineProps<{
subtitle: string subtitle: string
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music' current?: 'studio' | 'library' | 'director' | 'prompt-builder' | 'queue' | 'settings' | 'music'
wide?: boolean wide?: boolean
queueCount?: number queueCount?: number
}>(), { }>(), {
+1
View File
@@ -15,6 +15,7 @@
<div class="bench-header-right"> <div class="bench-header-right">
<nav class="bench-nav" aria-label="Studio links"> <nav class="bench-nav" aria-label="Studio links">
<NuxtLink to="/library">Library</NuxtLink> <NuxtLink to="/library">Library</NuxtLink>
<NuxtLink to="/prompt-builder">Prompt Builder</NuxtLink>
<NuxtLink to="/queue">Queue</NuxtLink> <NuxtLink to="/queue">Queue</NuxtLink>
<NuxtLink to="/settings">Settings</NuxtLink> <NuxtLink to="/settings">Settings</NuxtLink>
</nav> </nav>
+395
View File
@@ -0,0 +1,395 @@
<template>
<div class="min-h-dvh min-w-0">
<AppHeader subtitle="Prompt Builder" current="prompt-builder" />
<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">Prompt Builder</h2>
<p class="mt-1 max-w-2xl text-sm text-zinc-400">
Build multi-image prompt sequences. Copy the preview into Generate with Batch on — scenes split on the double blank line.
</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()"
@click="save"
>
{{ busy === 'save' ? 'Saving…' : selectedId ? 'Save' : 'Save sequence' }}
</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">Sequence 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. Badge set A"
>
<p class="mt-1 text-[11px] text-zinc-500">Save key only — never copied into the prompt text.</p>
</label>
<div class="flex flex-wrap items-center gap-2 border-b border-white/10 pb-2">
<button
v-for="(scene, index) in scenes"
:key="scene.id"
type="button"
class="rounded-full px-3 py-1.5 text-xs transition"
:class="index === activeIndex
? 'bg-amber-400/15 text-amber-100 ring-1 ring-amber-300/40'
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'"
@click="activeIndex = index"
>
{{ scene.title || `Scene ${index + 1}` }}
</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"
@click="addNextScene"
>
Next scene
</button>
</div>
<div v-if="activeScene" class="space-y-4">
<label class="block text-sm">
<span class="mb-1 flex items-center justify-between gap-2 font-medium text-zinc-300">
Scene tab name
<span class="text-[11px] font-normal text-zinc-500">optional</span>
</span>
<input
v-model="activeScene.title"
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="60"
:placeholder="`Scene ${activeIndex + 1}`"
>
</label>
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Style</span>
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="activeScene.style = ''">Clear</button>
</div>
<textarea
v-model="activeScene.style"
rows="3"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Visual style for this image"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Settings</span>
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="activeScene.settings = ''">Clear</button>
</div>
<textarea
v-model="activeScene.settings"
rows="3"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Lighting, framing, technical notes"
/>
</div>
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Characters</span>
<button
type="button"
class="rounded-full border border-white/10 px-3 py-1 text-[11px] text-zinc-300 hover:border-amber-300/50 hover:text-white"
@click="addCharacter"
>
Add character
</button>
</div>
<div
v-for="(character, cIndex) in activeScene.characters"
:key="character.id"
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-2"
>
<div class="flex items-center justify-between gap-2">
<span class="text-[11px] uppercase tracking-wide text-zinc-500">Character {{ cIndex + 1 }}</span>
<div class="flex items-center gap-2">
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="clearCharacter(cIndex)">Clear</button>
<button
v-if="activeScene.characters.length > 1"
type="button"
class="text-[11px] text-zinc-500 hover:text-red-300"
@click="removeCharacter(cIndex)"
>
Remove
</button>
</div>
</div>
<input
v-model="character.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="120"
placeholder="Name"
>
<textarea
v-model="character.description"
rows="2"
class="w-full rounded-xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Description"
/>
</div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Prompt</span>
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="activeScene.prompt = ''">Clear</button>
</div>
<textarea
v-model="activeScene.prompt"
rows="5"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="What happens in this image"
/>
</div>
</div>
<section class="rounded-2xl border border-white/10 bg-zinc-950/30">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">
<div>
<h3 class="text-sm font-medium text-zinc-200">Preview</h3>
<p class="text-[11px] text-zinc-500">Assembled text ready to copy. Sequence name is omitted.</p>
</div>
<div class="flex 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 disabled:opacity-50"
:disabled="!previewText"
@click="copyPreview"
>
{{ copied ? 'Copied' : 'Copy' }}
</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"
@click="previewOpen = !previewOpen"
>
{{ previewOpen ? 'Hide' : 'Show' }}
</button>
</div>
</div>
<pre
v-if="previewOpen"
class="max-h-[min(28rem,50vh)] overflow-auto whitespace-pre-wrap px-4 py-3 text-xs text-zinc-300 [overflow-wrap:anywhere]"
>{{ previewText || 'Fill a scene to see the assembled prompt.' }}</pre>
</section>
</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 sequences</h3>
<p class="mt-1 text-xs text-zinc-500">Stored with your other library data. Open one to edit.</p>
<p v-if="!sequences.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="sequence in sequences"
:key="sequence.id"
type="button"
class="w-full rounded-2xl border p-3 text-left transition"
:class="sequence.id === selectedId
? 'border-amber-300/30 bg-amber-400/5'
: 'border-white/5 bg-zinc-950/40 hover:border-white/15'"
@click="select(sequence)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-zinc-100">{{ sequence.name }}</p>
<p class="mt-0.5 text-[11px] text-zinc-500">
{{ sequence.scenes?.length || 0 }} scene{{ (sequence.scenes?.length || 0) === 1 ? '' : 's' }}
</p>
</div>
<button
type="button"
class="shrink-0 text-xs text-zinc-500 hover:text-red-300"
@click.stop="remove(sequence)"
>
Delete
</button>
</div>
</button>
</div>
</section>
</aside>
</main>
</div>
</template>
<script setup lang="ts">
import type { PromptSequence, PromptSequenceScene } from '~/utils/promptSequences'
import {
assembleSequenceText,
cloneSceneForNext,
emptyCharacter,
emptyScene
} from '~/utils/promptSequences'
useHead({ title: 'Prompt Builder' })
const sequences = ref<PromptSequence[]>([])
const selectedId = ref('')
const name = ref('')
const scenes = ref<PromptSequenceScene[]>([emptyScene(1)])
const activeIndex = ref(0)
const previewOpen = ref(true)
const busy = ref('')
const error = ref('')
const notice = ref('')
const copied = ref(false)
let copyTimer: ReturnType<typeof setTimeout> | null = null
const activeScene = computed(() => scenes.value[activeIndex.value] || null)
const previewText = computed(() => assembleSequenceText({ scenes: scenes.value }))
function startNew() {
selectedId.value = ''
name.value = ''
scenes.value = [emptyScene(1)]
activeIndex.value = 0
error.value = ''
notice.value = ''
}
function select(sequence: PromptSequence) {
selectedId.value = sequence.id
name.value = sequence.name
scenes.value = (sequence.scenes?.length ? sequence.scenes : [emptyScene(1)]).map((scene, index) => ({
...emptyScene(index + 1),
...scene,
characters: (scene.characters?.length ? scene.characters : [emptyCharacter()]).map(character => ({
...emptyCharacter(),
...character
}))
}))
activeIndex.value = 0
error.value = ''
notice.value = ''
}
function addNextScene() {
const previous = scenes.value[scenes.value.length - 1] || emptyScene(1)
const next = cloneSceneForNext(previous, scenes.value.length + 1)
scenes.value.push(next)
activeIndex.value = scenes.value.length - 1
}
function addCharacter() {
if (!activeScene.value) return
activeScene.value.characters.push(emptyCharacter())
}
function clearCharacter(index: number) {
const character = activeScene.value?.characters[index]
if (!character) return
character.name = ''
character.description = ''
}
function removeCharacter(index: number) {
if (!activeScene.value || activeScene.value.characters.length <= 1) return
activeScene.value.characters.splice(index, 1)
}
async function loadSequences() {
const listed = await $fetch<{ sequences?: PromptSequence[] }>('/api/prompt-sequences')
sequences.value = listed.sequences || []
}
async function save() {
const nextName = name.value.trim()
if (!nextName) return
const existingByName = sequences.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 sequence “${existingByName.name}”?`)) return
busy.value = 'save'
error.value = ''
notice.value = ''
try {
if (updateExisting && selectedId.value) {
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>(`/api/prompt-sequences/${selectedId.value}`, {
method: 'PATCH',
body: { name: nextName, scenes: scenes.value }
})
sequences.value = saved.sequences
select(saved.sequence)
notice.value = `Saved “${saved.sequence.name}”.`
} else {
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>('/api/prompt-sequences', {
method: 'POST',
body: { name: nextName, scenes: scenes.value, overwrite: Boolean(existingByName) }
})
sequences.value = saved.sequences
select(saved.sequence)
notice.value = existingByName ? `Overwrote “${saved.sequence.name}”.` : `Saved “${saved.sequence.name}”.`
}
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not save that sequence'
} finally {
busy.value = ''
}
}
async function remove(sequence: PromptSequence) {
if (!window.confirm(`Delete sequence “${sequence.name}”?`)) return
busy.value = 'delete'
error.value = ''
notice.value = ''
try {
const saved = await $fetch<{ sequences: PromptSequence[] }>(`/api/prompt-sequences/${sequence.id}`, { method: 'DELETE' })
sequences.value = saved.sequences
if (selectedId.value === sequence.id) startNew()
notice.value = `Deleted “${sequence.name}”.`
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not delete that sequence'
} finally {
busy.value = ''
}
}
async function copyPreview() {
const text = previewText.value
if (!text) return
try {
await navigator.clipboard.writeText(text)
copied.value = true
if (copyTimer) clearTimeout(copyTimer)
copyTimer = setTimeout(() => { copied.value = false }, 1500)
} catch {
error.value = 'Could not copy to the clipboard'
}
}
onMounted(async () => {
try {
await loadSequences()
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.message || 'Could not load saved sequences'
}
})
onBeforeUnmount(() => {
if (copyTimer) clearTimeout(copyTimer)
})
</script>
+8
View File
@@ -0,0 +1,8 @@
import { listPromptSequences } from '~/server/utils/promptSequences'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
return {
sequences: listPromptSequences(owner)
}
})
+19
View File
@@ -0,0 +1,19 @@
import { listPromptSequences, savePromptSequence } from '~/server/utils/promptSequences'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const body = await readBody<{
name?: string
scenes?: unknown
overwrite?: boolean
}>(event).catch(() => ({}))
const sequence = await savePromptSequence(owner, {
name: body?.name || '',
scenes: body?.scenes,
overwrite: body?.overwrite === true
})
return {
sequence,
sequences: listPromptSequences(owner)
}
})
@@ -0,0 +1,11 @@
import { deletePromptSequence, listPromptSequences } from '~/server/utils/promptSequences'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
await deletePromptSequence(owner, id)
return {
ok: true,
sequences: listPromptSequences(owner)
}
})
+9
View File
@@ -0,0 +1,9 @@
import { getPromptSequence } from '~/server/utils/promptSequences'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
return {
sequence: getPromptSequence(owner, id)
}
})
+15
View File
@@ -0,0 +1,15 @@
import { listPromptSequences, updatePromptSequence } from '~/server/utils/promptSequences'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ name?: string; scenes?: unknown }>(event).catch(() => ({}))
const sequence = await updatePromptSequence(owner, id, {
name: body?.name,
scenes: body?.scenes
})
return {
sequence,
sequences: listPromptSequences(owner)
}
})
+137
View File
@@ -0,0 +1,137 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { ownerRoot } from '~/server/utils/library'
import {
normalizePromptSequence,
normalizePromptSequenceName,
normalizeScene,
emptyScene,
type PromptSequence,
type PromptSequenceScene
} from '~/utils/promptSequences'
const writeChains = new Map<string, Promise<unknown>>()
function sequencesPath(owner: string) {
return join(ownerRoot(owner), 'prompt-sequences.json')
}
function ensureOwner(owner: string) {
mkdirSync(ownerRoot(owner), { recursive: true })
}
function readSequences(owner: string): PromptSequence[] {
ensureOwner(owner)
const path = sequencesPath(owner)
if (!existsSync(path)) return []
try {
const parsed = JSON.parse(readFileSync(path, 'utf8'))
return Array.isArray(parsed)
? parsed.map(normalizePromptSequence).filter((item): item is PromptSequence => Boolean(item))
: []
} catch {
return []
}
}
function writeSequences(owner: string, sequences: PromptSequence[]) {
ensureOwner(owner)
const path = sequencesPath(owner)
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(sequences, null, 2))
renameSync(tmp, path)
}
function mutate<T>(owner: string, fn: (sequences: PromptSequence[]) => T): Promise<T> {
const prev = writeChains.get(owner) || Promise.resolve()
const run = prev.then(() => {
const sequences = readSequences(owner)
const result = fn(sequences)
writeSequences(owner, sequences)
return result
})
writeChains.set(owner, run.then(() => undefined, () => undefined))
return run
}
function normalizeScenesInput(raw: unknown): PromptSequenceScene[] {
if (!Array.isArray(raw) || !raw.length) return [emptyScene(1)]
return raw.map((scene, index) => normalizeScene(scene, index))
}
export function listPromptSequences(owner: string) {
return readSequences(owner).sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name))
}
export function getPromptSequence(owner: string, id: string) {
const wanted = String(id || '').trim()
const sequence = readSequences(owner).find(item => item.id === wanted)
if (!sequence) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
return sequence
}
export async function savePromptSequence(owner: string, input: {
name: string
scenes?: unknown
overwrite?: boolean
}) {
const name = normalizePromptSequenceName(input.name)
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
const scenes = normalizeScenesInput(input.scenes)
const now = Date.now()
return mutate(owner, (sequences) => {
const existing = sequences.find(item => item.name.toLowerCase() === name.toLowerCase())
if (existing && !input.overwrite) {
throw createError({ statusCode: 409, statusMessage: `Overwrite sequence “${existing.name}”?` })
}
if (existing) {
existing.scenes = scenes
existing.updatedAt = now
return existing
}
const created: PromptSequence = {
id: crypto.randomUUID(),
name,
scenes,
createdAt: now,
updatedAt: now
}
sequences.unshift(created)
return created
})
}
export async function updatePromptSequence(owner: string, id: string, input: {
name?: string
scenes?: unknown
}) {
const wanted = String(id || '').trim()
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
return mutate(owner, (sequences) => {
const existing = sequences.find(item => item.id === wanted)
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
if (input.name !== undefined) {
const name = normalizePromptSequenceName(input.name)
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
const clash = sequences.find(item => item.id !== wanted && item.name.toLowerCase() === name.toLowerCase())
if (clash) throw createError({ statusCode: 409, statusMessage: `A sequence named “${clash.name}” already exists` })
existing.name = name
}
if (input.scenes !== undefined) {
existing.scenes = normalizeScenesInput(input.scenes)
}
existing.updatedAt = Date.now()
return existing
})
}
export async function deletePromptSequence(owner: string, id: string) {
const wanted = String(id || '').trim()
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
return mutate(owner, (sequences) => {
const index = sequences.findIndex(item => item.id === wanted)
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
const [removed] = sequences.splice(index, 1)
return removed
})
}
+68
View File
@@ -0,0 +1,68 @@
/** Pure prompt-sequence helpers (shared by page + tests). */
export function newId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `ps-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
export function emptyCharacter() {
return { id: newId(), name: '', description: '' }
}
export function emptyScene(index = 1) {
return {
id: newId(),
title: `Scene ${Math.max(1, index)}`,
style: '',
settings: '',
characters: [emptyCharacter()],
prompt: ''
}
}
export function cloneSceneForNext(previous, nextIndex) {
return {
id: newId(),
title: `Scene ${nextIndex}`,
style: previous.style,
settings: previous.settings,
characters: (previous.characters || []).map(character => ({
id: newId(),
name: character.name,
description: character.description
})),
prompt: previous.prompt
}
}
export function formatCharacterLine(character) {
const name = String(character?.name || '').trim()
const description = String(character?.description || '').trim()
if (!name && !description) return ''
if (name && description) return `${name}: ${description}`
return name || description
}
export function assembleSceneText(scene) {
const characterBlock = (scene?.characters || [])
.map(formatCharacterLine)
.filter(Boolean)
.join('\n')
const parts = [
String(scene?.style || '').trim(),
String(scene?.settings || '').trim(),
characterBlock,
String(scene?.prompt || '').trim()
].filter(Boolean)
return parts.join('\n\n')
}
/** Scenes join with \\n\\n\\n (desktop Batch double blank). Name is never included. */
export function assembleSequenceText(sequence) {
return (sequence?.scenes || [])
.map(assembleSceneText)
.filter(Boolean)
.join('\n\n\n')
}
+99
View File
@@ -0,0 +1,99 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
assembleSceneText,
assembleSequenceText,
formatCharacterLine,
emptyScene,
cloneSceneForNext
} from '../shared/prompt-sequences.mjs'
test('formatCharacterLine covers name/description cases', () => {
assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'Ada: tall')
assert.equal(formatCharacterLine({ name: 'Ada', description: '' }), 'Ada')
assert.equal(formatCharacterLine({ name: '', description: 'tall' }), 'tall')
assert.equal(formatCharacterLine({ name: '', description: '' }), '')
})
test('assembleSceneText joins non-empty boxes with a single blank line', () => {
const text = assembleSceneText({
style: 'oil paint',
settings: 'night',
characters: [
{ name: 'Ada', description: 'tall' },
{ name: '', description: '' },
{ name: 'Bo', description: 'hat' }
],
prompt: 'walks'
})
assert.equal(text, 'oil paint\n\nnight\n\nAda: tall\nBo: hat\n\nwalks')
})
test('assembleSequenceText uses double blank line between scenes and skips empties', () => {
const text = assembleSequenceText({
name: 'ignored',
scenes: [
{ style: 's1', settings: '', characters: [], prompt: 'p1' },
{ style: '', settings: '', characters: [{ name: '', description: '' }], prompt: '' },
{ style: 's2', settings: 'set2', characters: [{ name: 'C', description: 'd' }], prompt: 'p2' }
]
})
assert.equal(text, 's1\n\np1\n\n\ns2\n\nset2\n\nC: d\n\np2')
assert.ok(!text.includes('ignored'))
})
test('example shape is two jobs for desktop Batch', () => {
const text = assembleSequenceText({
scenes: [
{
style: 'style scene 1',
settings: 'settings scene 1',
characters: [
{ name: 'Character one', description: 'desc' },
{ name: 'Character two', description: 'desc' }
],
prompt: 'prompt scene 1'
},
{
style: 'style scene 2',
settings: 'settings scene 2',
characters: [{ name: 'Character one', description: 'desc' }],
prompt: 'prompt scene 2'
}
]
})
assert.equal(
text,
[
'style scene 1',
'',
'settings scene 1',
'',
'Character one: desc',
'Character two: desc',
'',
'prompt scene 1',
'',
'',
'style scene 2',
'',
'settings scene 2',
'',
'Character one: desc',
'',
'prompt scene 2'
].join('\n')
)
})
test('cloneSceneForNext copies fields with new ids', () => {
const first = emptyScene(1)
first.style = 'a'
first.characters[0].name = 'Ada'
const next = cloneSceneForNext(first, 2)
assert.equal(next.title, 'Scene 2')
assert.equal(next.style, 'a')
assert.equal(next.characters[0].name, 'Ada')
assert.notEqual(next.id, first.id)
assert.notEqual(next.characters[0].id, first.characters[0].id)
})
+96
View File
@@ -0,0 +1,96 @@
export type PromptSequenceCharacter = {
id: string
name: string
description: string
}
export type PromptSequenceScene = {
id: string
title: string
style: string
settings: string
characters: PromptSequenceCharacter[]
prompt: string
}
export type PromptSequence = {
id: string
name: string
scenes: PromptSequenceScene[]
createdAt: number
updatedAt: number
}
export const PROMPT_SEQUENCE_NAME_MAX = 80
export const PROMPT_SEQUENCE_FIELD_MAX = 50_000
export const PROMPT_SEQUENCE_TITLE_MAX = 60
export {
newId,
emptyCharacter,
emptyScene,
cloneSceneForNext,
formatCharacterLine,
assembleSceneText,
assembleSequenceText
} from '../shared/prompt-sequences.mjs'
import {
newId,
emptyCharacter,
emptyScene
} from '../shared/prompt-sequences.mjs'
export function normalizePromptSequenceName(raw: unknown) {
return String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_NAME_MAX)
}
export function normalizePromptSequenceField(raw: unknown) {
return String(raw || '').replace(/\r\n/g, '\n').slice(0, PROMPT_SEQUENCE_FIELD_MAX)
}
export function normalizePromptSequenceTitle(raw: unknown, fallback: string) {
const title = String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_TITLE_MAX)
return title || fallback
}
export function normalizeCharacter(raw: unknown): PromptSequenceCharacter {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
return {
id: String(rec.id || '').trim() || newId(),
name: String(rec.name || '').trim().slice(0, 120),
description: normalizePromptSequenceField(rec.description).trim()
}
}
export function normalizeScene(raw: unknown, index: number): PromptSequenceScene {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const characters = Array.isArray(rec.characters)
? rec.characters.map(normalizeCharacter)
: [emptyCharacter()]
return {
id: String(rec.id || '').trim() || newId(),
title: normalizePromptSequenceTitle(rec.title, `Scene ${index + 1}`),
style: normalizePromptSequenceField(rec.style),
settings: normalizePromptSequenceField(rec.settings),
characters: characters.length ? characters : [emptyCharacter()],
prompt: normalizePromptSequenceField(rec.prompt)
}
}
export function normalizePromptSequence(raw: unknown): PromptSequence | null {
if (!raw || typeof raw !== 'object') return null
const rec = raw as Record<string, unknown>
const name = normalizePromptSequenceName(rec.name)
if (!name) return null
const scenesRaw = Array.isArray(rec.scenes) ? rec.scenes : []
const scenes = scenesRaw.map((scene, index) => normalizeScene(scene, index))
const createdAt = Number(rec.createdAt) || Date.now()
return {
id: String(rec.id || '').trim() || newId(),
name,
scenes: scenes.length ? scenes : [emptyScene(1)],
createdAt,
updatedAt: Number(rec.updatedAt) || createdAt
}
}