Add Prompt Builder bang ignores and facing-aware character features.
Strip !...! at compile time, and omit character features the active framing facing cannot see while keeping them checked in the editor. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+277
-3
@@ -116,18 +116,84 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="block text-sm">
|
||||
<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"
|
||||
>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">Label only — not copied into the prompt.</p>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block text-[11px] text-zinc-500">Base description</span>
|
||||
<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"
|
||||
placeholder="Base description (always included when in frame; no face tokens)"
|
||||
/>
|
||||
</label>
|
||||
<div class="space-y-2 border-t border-white/5 pt-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Features</span>
|
||||
<p class="text-[11px] text-zinc-500">Dropped when the camera facing cannot see them.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="addFeature(cIndex)"
|
||||
>
|
||||
Add feature
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!(character.features || []).length" class="text-[11px] text-zinc-600">No features yet.</p>
|
||||
<div
|
||||
v-for="(feature, featIndex) in (character.features || [])"
|
||||
:key="feature.id"
|
||||
class="rounded-xl border border-white/5 bg-zinc-950/50 p-2 space-y-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-300">
|
||||
<input v-model="feature.enabled" type="checkbox" class="accent-amber-400">
|
||||
<span class="text-[11px] text-zinc-500">On</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="feature.text"
|
||||
class="min-w-0 flex-1 rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Feature text"
|
||||
>
|
||||
<select
|
||||
class="rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"
|
||||
:value="feature.group"
|
||||
@change="setFeatureGroup(cIndex, featIndex, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="group in PROMPT_SEQUENCE_FEATURE_GROUPS" :key="group" :value="group">
|
||||
{{ group }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="chip in visibilityChipKeys"
|
||||
:key="chip"
|
||||
type="button"
|
||||
class="rounded-full px-2.5 min-h-9 py-1 text-[11px] font-semibold transition"
|
||||
:class="isVisibilityChipActive(feature, chip)
|
||||
? 'bg-amber-400/15 text-amber-100 ring-1 ring-amber-300/40'
|
||||
: 'border border-white/15 text-zinc-400 hover:border-white/30 hover:text-zinc-200'"
|
||||
@click="toggleVisibilityChip(feature, chip)"
|
||||
>
|
||||
{{ chip }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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="clearFeature(cIndex, featIndex)">Clear</button>
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="removeFeature(cIndex, featIndex)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 border-t border-white/5 pt-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Outfits</span>
|
||||
@@ -243,6 +309,40 @@
|
||||
class="w-full rounded-lg border border-white/10 bg-zinc-950/70 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Place / location text copied into the prompt"
|
||||
/>
|
||||
<label class="block text-[11px] text-zinc-500">
|
||||
Character facing
|
||||
<select
|
||||
class="mt-1 w-full rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"
|
||||
:value="framing.facing || 'toward'"
|
||||
@change="framing.facing = (($event.target as HTMLSelectElement).value as typeof framing.facing) || 'toward'"
|
||||
>
|
||||
<option v-for="facing in PROMPT_SEQUENCE_FACINGS" :key="facing" :value="facing">
|
||||
{{ facing }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div
|
||||
v-for="preview in [framingPreview(framing)]"
|
||||
:key="'framing-preview-' + framing.id"
|
||||
class="rounded-lg border border-white/5 bg-zinc-950/70 px-2.5 py-2 space-y-1.5"
|
||||
>
|
||||
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Framing preview</span>
|
||||
<pre class="whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ preview.text || '—' }}</pre>
|
||||
<div v-if="preview.ignoredBangs.length" class="text-[11px] text-zinc-500">
|
||||
Ignored bangs:
|
||||
<span class="text-zinc-400">{{ preview.ignoredBangs.join(', ') }}</span>
|
||||
</div>
|
||||
<div v-if="preview.omittedFeatures.length" class="space-y-0.5">
|
||||
<span class="text-[11px] text-zinc-500">Omitted (camera):</span>
|
||||
<p
|
||||
v-for="item in preview.omittedFeatures"
|
||||
:key="item.id"
|
||||
class="text-[11px] text-zinc-500 line-through"
|
||||
>
|
||||
{{ item.text || '(empty feature)' }} — camera
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -458,6 +558,25 @@
|
||||
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>
|
||||
<div
|
||||
v-if="previewOpen && (activeSceneIgnoredBangs.length || activeSceneOmittedFeatures.length)"
|
||||
class="space-y-1.5 border-t border-white/5 px-4 py-3"
|
||||
>
|
||||
<div v-if="activeSceneIgnoredBangs.length" class="text-[11px] text-zinc-500">
|
||||
Ignored bangs:
|
||||
<span class="text-zinc-400">{{ activeSceneIgnoredBangs.join(', ') }}</span>
|
||||
</div>
|
||||
<div v-if="activeSceneOmittedFeatures.length" class="space-y-0.5">
|
||||
<span class="text-[11px] text-zinc-500">Omitted (camera):</span>
|
||||
<p
|
||||
v-for="item in activeSceneOmittedFeatures"
|
||||
:key="item.id"
|
||||
class="text-[11px] text-zinc-500 line-through"
|
||||
>
|
||||
{{ item.text || '(empty feature)' }} — camera
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -507,25 +626,37 @@
|
||||
import type {
|
||||
PromptSequence,
|
||||
PromptSequenceCharacter,
|
||||
PromptSequenceFacing,
|
||||
PromptSequenceFeature,
|
||||
PromptSequenceFraming,
|
||||
PromptSequenceScene,
|
||||
PromptSequenceSectionKey,
|
||||
PromptSequenceSet
|
||||
} from '~/utils/promptSequences'
|
||||
import {
|
||||
DEFAULT_FEATURE_VISIBILITY,
|
||||
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||
PROMPT_SEQUENCE_FACINGS,
|
||||
PROMPT_SEQUENCE_FEATURE_GROUPS,
|
||||
PROMPT_SEQUENCE_FRAMING_SUGGESTIONS,
|
||||
assembleSceneText,
|
||||
assembleSequenceText,
|
||||
cloneSceneForNext,
|
||||
compileCharacterForFacing,
|
||||
defaultVisibilityForGroup,
|
||||
emptyCharacter,
|
||||
emptyFeature,
|
||||
emptyFraming,
|
||||
emptyOutfit,
|
||||
emptyScene,
|
||||
emptySet,
|
||||
ensureSceneStatesForCharacters,
|
||||
ensureSceneStatesForSets,
|
||||
extractBangPhrases,
|
||||
normalizePromptSequence,
|
||||
normalizeSectionOrder
|
||||
normalizeSectionOrder,
|
||||
resolveSceneFacing,
|
||||
stripBangs
|
||||
} from '~/utils/promptSequences'
|
||||
|
||||
useHead({ title: 'Prompt Builder' })
|
||||
@@ -538,6 +669,10 @@ const SECTION_LABELS: Record<PromptSequenceSectionKey, string> = {
|
||||
prompt: 'Prompt'
|
||||
}
|
||||
|
||||
type VisibilityChipKey = PromptSequenceFacing | 'any'
|
||||
|
||||
const visibilityChipKeys: VisibilityChipKey[] = [...PROMPT_SEQUENCE_FACINGS, 'any']
|
||||
|
||||
const sequences = ref<PromptSequence[]>([])
|
||||
const selectedId = ref('')
|
||||
const name = ref('')
|
||||
@@ -568,6 +703,72 @@ const activeSceneText = computed(() => {
|
||||
return assembleSceneText(activeScene.value, characters.value, sets.value, sectionOrder.value)
|
||||
})
|
||||
|
||||
type FramingPreviewResult = {
|
||||
text: string
|
||||
ignoredBangs: string[]
|
||||
omittedFeatures: Array<{ id: string; text: string; reason: 'camera' }>
|
||||
}
|
||||
|
||||
function buildFramingPreview(
|
||||
framing: PromptSequenceFraming,
|
||||
opts?: { characters?: PromptSequenceCharacter[]; characterStates?: PromptSequenceScene['characterStates'] }
|
||||
): FramingPreviewResult {
|
||||
const facing = (framing.facing || 'toward') as PromptSequenceFacing
|
||||
const previewCharacters = opts?.characters || characters.value
|
||||
const stateById = new Map((opts?.characterStates || []).map(state => [state.characterId, state]))
|
||||
const onCharacters = opts?.characterStates
|
||||
? previewCharacters.filter((character) => {
|
||||
const state = stateById.get(character.id)
|
||||
return state ? !!state.on : true
|
||||
})
|
||||
: previewCharacters
|
||||
const framingClean = stripBangs(String(framing.text || ''))
|
||||
const compiled = onCharacters.map(character => compileCharacterForFacing(character, facing))
|
||||
const text = [framingClean, ...compiled.map(item => item.text).filter(Boolean)].filter(Boolean).join('\n')
|
||||
const ignoredBangs = [
|
||||
...extractBangPhrases(String(framing.text || '')),
|
||||
...compiled.flatMap(item => item.ignoredBangs || [])
|
||||
]
|
||||
const omittedFeatures = compiled.flatMap(item => item.omittedFeatures || [])
|
||||
return { text, ignoredBangs, omittedFeatures }
|
||||
}
|
||||
|
||||
function framingPreview(framing: PromptSequenceFraming) {
|
||||
return buildFramingPreview(framing)
|
||||
}
|
||||
|
||||
const activeSceneDiagnostics = computed(() => {
|
||||
if (!activeScene.value) {
|
||||
return { ignoredBangs: [] as string[], omittedFeatures: [] as Array<{ id: string; text: string; reason: 'camera' }> }
|
||||
}
|
||||
const facing = resolveSceneFacing(activeScene.value, sets.value)
|
||||
const onCharacters = characters.value.filter((character) => {
|
||||
const state = activeScene.value?.characterStates?.find(item => item.characterId === character.id)
|
||||
return state ? !!state.on : true
|
||||
})
|
||||
const ignoredBangs = new Set<string>()
|
||||
const omittedFeatures: Array<{ id: string; text: string; reason: 'camera' }> = []
|
||||
for (const set of sets.value) {
|
||||
const state = activeScene.value.setStates?.find(item => item.setId === set.id)
|
||||
if (state && !state.on) continue
|
||||
const framing = (set.framings || []).find(item => item.id === state?.framingId)
|
||||
if (!framing) continue
|
||||
for (const bang of extractBangPhrases(String(framing.text || ''))) ignoredBangs.add(bang)
|
||||
}
|
||||
for (const character of onCharacters) {
|
||||
const compiled = compileCharacterForFacing(character, facing as PromptSequenceFacing)
|
||||
for (const bang of compiled.ignoredBangs || []) ignoredBangs.add(bang)
|
||||
omittedFeatures.push(...(compiled.omittedFeatures || []))
|
||||
}
|
||||
for (const bang of extractBangPhrases(String(activeScene.value.style || ''))) ignoredBangs.add(bang)
|
||||
for (const bang of extractBangPhrases(String(activeScene.value.settings || ''))) ignoredBangs.add(bang)
|
||||
for (const bang of extractBangPhrases(String(activeScene.value.prompt || ''))) ignoredBangs.add(bang)
|
||||
return { ignoredBangs: [...ignoredBangs], omittedFeatures }
|
||||
})
|
||||
|
||||
const activeSceneIgnoredBangs = computed(() => activeSceneDiagnostics.value.ignoredBangs)
|
||||
const activeSceneOmittedFeatures = computed(() => activeSceneDiagnostics.value.omittedFeatures)
|
||||
|
||||
function sectionLabel(key: PromptSequenceSectionKey) {
|
||||
return SECTION_LABELS[key] || key
|
||||
}
|
||||
@@ -628,12 +829,29 @@ function select(sequence: PromptSequence) {
|
||||
characters.value = (normalized.characters?.length ? normalized.characters : [emptyCharacter()]).map(character => ({
|
||||
...emptyCharacter(),
|
||||
...character,
|
||||
features: (character.features || []).map((feature) => {
|
||||
const group = feature.group || emptyFeature().group
|
||||
const fallbackVisibility = defaultVisibilityForGroup(group)
|
||||
|| [...(DEFAULT_FEATURE_VISIBILITY[group] || [])]
|
||||
return {
|
||||
...emptyFeature(),
|
||||
...feature,
|
||||
group,
|
||||
visibility: Array.isArray(feature.visibility) && feature.visibility.length
|
||||
? [...feature.visibility]
|
||||
: [...fallbackVisibility]
|
||||
}
|
||||
}),
|
||||
outfits: (character.outfits || []).map(outfit => ({ ...emptyOutfit(), ...outfit }))
|
||||
}))
|
||||
sets.value = (normalized.sets || []).map(set => ({
|
||||
...emptySet(),
|
||||
...set,
|
||||
framings: (set.framings?.length ? set.framings : [emptyFraming()]).map(framing => ({ ...emptyFraming(), ...framing }))
|
||||
framings: (set.framings?.length ? set.framings : [emptyFraming()]).map(framing => ({
|
||||
...emptyFraming(),
|
||||
...framing,
|
||||
facing: framing.facing || 'toward'
|
||||
}))
|
||||
}))
|
||||
scenes.value = (normalized.scenes?.length ? normalized.scenes : [emptyScene(1, characters.value, sets.value)]).map((scene, index) => ({
|
||||
...emptyScene(index + 1, characters.value, sets.value),
|
||||
@@ -702,6 +920,7 @@ function clearCharacter(index: number) {
|
||||
if (!character) return
|
||||
character.name = ''
|
||||
character.description = ''
|
||||
character.features = []
|
||||
character.outfits = []
|
||||
for (const scene of scenes.value) {
|
||||
const state = scene.characterStates.find(item => item.characterId === character.id)
|
||||
@@ -709,6 +928,60 @@ function clearCharacter(index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function addFeature(characterIndex: number) {
|
||||
const character = characters.value[characterIndex]
|
||||
if (!character) return
|
||||
if (!character.features) character.features = []
|
||||
character.features.push(emptyFeature())
|
||||
}
|
||||
|
||||
function clearFeature(characterIndex: number, featureIndex: number) {
|
||||
const feature = characters.value[characterIndex]?.features?.[featureIndex]
|
||||
if (!feature) return
|
||||
const blank = emptyFeature()
|
||||
feature.text = blank.text
|
||||
feature.enabled = blank.enabled
|
||||
feature.group = blank.group
|
||||
feature.visibility = [...blank.visibility]
|
||||
}
|
||||
|
||||
function removeFeature(characterIndex: number, featureIndex: number) {
|
||||
const character = characters.value[characterIndex]
|
||||
if (!character?.features) return
|
||||
character.features.splice(featureIndex, 1)
|
||||
}
|
||||
|
||||
function setFeatureGroup(characterIndex: number, featureIndex: number, group: string) {
|
||||
const feature = characters.value[characterIndex]?.features?.[featureIndex]
|
||||
if (!feature) return
|
||||
feature.group = group as PromptSequenceFeature['group']
|
||||
const next = defaultVisibilityForGroup(feature.group)
|
||||
|| [...(DEFAULT_FEATURE_VISIBILITY[feature.group] || [])]
|
||||
feature.visibility = [...next]
|
||||
}
|
||||
|
||||
function featureVisibilityList(feature: PromptSequenceFeature): VisibilityChipKey[] {
|
||||
return (feature.visibility || []) as VisibilityChipKey[]
|
||||
}
|
||||
|
||||
function isVisibilityChipActive(feature: PromptSequenceFeature, key: VisibilityChipKey) {
|
||||
const visibility = featureVisibilityList(feature)
|
||||
if (key === 'any') return visibility.includes('any') || visibility.length === 0
|
||||
return visibility.includes(key)
|
||||
}
|
||||
|
||||
function toggleVisibilityChip(feature: PromptSequenceFeature, key: VisibilityChipKey) {
|
||||
if (key === 'any') {
|
||||
feature.visibility = ['any'] as PromptSequenceFeature['visibility']
|
||||
return
|
||||
}
|
||||
const withoutAny = featureVisibilityList(feature).filter(item => item !== 'any') as PromptSequenceFacing[]
|
||||
const index = withoutAny.indexOf(key)
|
||||
if (index >= 0) withoutAny.splice(index, 1)
|
||||
else withoutAny.push(key)
|
||||
feature.visibility = (withoutAny.length ? withoutAny : ['any']) as PromptSequenceFeature['visibility']
|
||||
}
|
||||
|
||||
function removeCharacter(index: number) {
|
||||
if (characters.value.length <= 1) return
|
||||
const [removed] = characters.value.splice(index, 1)
|
||||
@@ -785,6 +1058,7 @@ function clearFraming(setIndex: number, framingIndex: number) {
|
||||
if (!framing) return
|
||||
framing.label = ''
|
||||
framing.text = ''
|
||||
framing.facing = 'toward'
|
||||
}
|
||||
|
||||
function sceneSetState(setId: string) {
|
||||
|
||||
@@ -24,22 +24,33 @@ const {
|
||||
emptyOutfit,
|
||||
emptySet,
|
||||
emptyFraming,
|
||||
emptyFeature,
|
||||
cloneSceneForNext,
|
||||
normalizePromptSequence,
|
||||
normalizeSectionOrder,
|
||||
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||
ensureSceneStatesForCharacters
|
||||
ensureSceneStatesForCharacters,
|
||||
stripBangs,
|
||||
extractBangPhrases,
|
||||
compileCharacterForFacing,
|
||||
defaultVisibilityForGroup
|
||||
} = await import(pathToFileURL(tmp).href)
|
||||
unlinkSync(tmp)
|
||||
|
||||
test('formatCharacterLine covers name/description and outfit', () => {
|
||||
assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'Ada: tall')
|
||||
test('formatCharacterLine copies base and outfit, never name', () => {
|
||||
assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'tall')
|
||||
assert.equal(
|
||||
formatCharacterLine({ name: 'Ada', description: 'tall' }, { name: 'Work', description: 'blue jacket' }),
|
||||
'Ada: tall. Outfit: blue jacket'
|
||||
'tall. Outfit: blue jacket'
|
||||
)
|
||||
})
|
||||
|
||||
test('stripBangs removes inline ignore spans and cleans commas', () => {
|
||||
assert.equal(stripBangs('young woman, !brown eyes!, linen robe'), 'young woman, linen robe')
|
||||
assert.equal(stripBangs('a, !x!, b'), 'a, b')
|
||||
assert.deepEqual(extractBangPhrases('young woman, !brown eyes!, linen robe'), ['brown eyes'])
|
||||
})
|
||||
|
||||
test('selecting a framing copies only that framing text; labels never appear', () => {
|
||||
const wide = { ...emptyFraming('wide'), id: 'f-wide', text: 'open courtyard at dawn' }
|
||||
const medium = { ...emptyFraming('medium'), id: 'f-med', text: 'courtyard from the arch' }
|
||||
@@ -137,6 +148,7 @@ test('legacy beat-only set still opens as one wide framing', () => {
|
||||
assert.equal(legacy.sets.length, 1)
|
||||
assert.equal(legacy.sets[0].framings.length, 1)
|
||||
assert.equal(legacy.sets[0].framings[0].label, 'wide')
|
||||
assert.equal(legacy.sets[0].framings[0].facing, 'toward')
|
||||
assert.ok(legacy.sets[0].framings[0].text.includes('stone walls'))
|
||||
assert.ok(legacy.sets[0].framings[0].text.includes('first beat'))
|
||||
assert.ok(legacy.sets[0].framings[0].text.includes('second beat'))
|
||||
@@ -166,7 +178,8 @@ test('legacy sequence without sets still assembles', () => {
|
||||
updatedAt: 1
|
||||
})
|
||||
assert.deepEqual(legacy.sets, [])
|
||||
assert.equal(assembleSequenceText(legacy), 'oil\n\nAda: tall\n\nwalk')
|
||||
assert.equal(assembleSequenceText(legacy), 'oil\n\ntall\n\nwalk')
|
||||
assert.ok(!assembleSequenceText(legacy).includes('Ada'))
|
||||
})
|
||||
|
||||
test('cloneSceneForNext copies setStates including framingId', () => {
|
||||
@@ -195,7 +208,8 @@ test('character outfit and off still work with sets', () => {
|
||||
characterStates: [{ characterId: 'c1', on: true, outfitId: 'o1' }],
|
||||
prompt: 'hi'
|
||||
}, [ada], [])
|
||||
assert.equal(text, 'Ada: tall. Outfit: red dress\n\nhi')
|
||||
assert.equal(text, 'tall. Outfit: red dress\n\nhi')
|
||||
assert.ok(!text.includes('Ada'))
|
||||
})
|
||||
|
||||
test('ensureSceneStatesForCharacters turns new character on only for current scene', () => {
|
||||
@@ -230,7 +244,7 @@ test('sectionOrder settings then prompt then characters; empty style and set omi
|
||||
}
|
||||
const order = ['settings', 'prompt', 'characters']
|
||||
const text = assembleSceneText(scene, [ada], [set], order)
|
||||
assert.equal(text, 'soft light\n\nlooks up\n\nAda: tall')
|
||||
assert.equal(text, 'soft light\n\nlooks up\n\ntall')
|
||||
assert.ok(!text.includes('open yard'))
|
||||
assert.ok(!text.includes('Courtyard'))
|
||||
assert.ok(!text.includes('wide'))
|
||||
@@ -281,8 +295,117 @@ test('legacy sequence without sectionOrder keeps default order and \\n\\n\\n joi
|
||||
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'
|
||||
'oil\n\nday\n\nstone hall\n\ntall\n\nwalk\n\n\nink\n\ntall\n\nstop'
|
||||
)
|
||||
assert.ok(!full.includes('Hall'))
|
||||
assert.ok(!full.includes('wide'))
|
||||
assert.ok(!full.includes('Ada'))
|
||||
})
|
||||
|
||||
test('bang strip applies to set framing and character text before join', () => {
|
||||
const eyes = {
|
||||
...emptyFeature('eyes'),
|
||||
id: 'feat-eyes',
|
||||
text: '!brown eyes!',
|
||||
enabled: true,
|
||||
visibility: defaultVisibilityForGroup('eyes')
|
||||
}
|
||||
const ada = {
|
||||
...emptyCharacter(),
|
||||
id: 'c1',
|
||||
name: 'Ada',
|
||||
description: 'young woman, !scar!, linen robe',
|
||||
features: [eyes]
|
||||
}
|
||||
const set = {
|
||||
...emptySet(),
|
||||
id: 's1',
|
||||
name: 'Yard',
|
||||
framings: [{
|
||||
...emptyFraming('wide'),
|
||||
id: 'f1',
|
||||
text: 'courtyard, !prop note!, dusk',
|
||||
facing: 'toward'
|
||||
}]
|
||||
}
|
||||
const text = assembleSceneText({
|
||||
style: '',
|
||||
settings: '',
|
||||
setStates: [{ setId: 's1', on: true, framingId: 'f1' }],
|
||||
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
|
||||
prompt: ''
|
||||
}, [ada], [set])
|
||||
assert.equal(text, 'courtyard, dusk\n\nyoung woman, linen robe')
|
||||
assert.ok(!text.includes('brown eyes'))
|
||||
assert.ok(!text.includes('scar'))
|
||||
assert.ok(!text.includes('prop note'))
|
||||
assert.ok(!text.includes('!'))
|
||||
assert.ok(!text.includes('Ada'))
|
||||
assert.ok(!text.includes('Yard'))
|
||||
})
|
||||
|
||||
test('facing away drops eyes; feature stays omitted with camera reason; out emits no character', () => {
|
||||
const eyes = {
|
||||
...emptyFeature('eyes'),
|
||||
id: 'feat-eyes',
|
||||
text: 'brown eyes',
|
||||
enabled: true,
|
||||
visibility: defaultVisibilityForGroup('eyes')
|
||||
}
|
||||
const hairBack = {
|
||||
...emptyFeature('hair back'),
|
||||
id: 'feat-hair',
|
||||
text: 'long braid',
|
||||
enabled: true,
|
||||
visibility: defaultVisibilityForGroup('hair back')
|
||||
}
|
||||
const clothing = {
|
||||
...emptyFeature('clothing'),
|
||||
id: 'feat-clothes',
|
||||
text: 'linen robe',
|
||||
enabled: true,
|
||||
visibility: defaultVisibilityForGroup('clothing')
|
||||
}
|
||||
const ada = {
|
||||
...emptyCharacter(),
|
||||
id: 'c1',
|
||||
name: 'Ada',
|
||||
description: 'young woman',
|
||||
features: [eyes, hairBack, clothing]
|
||||
}
|
||||
|
||||
const away = compileCharacterForFacing(ada, 'away')
|
||||
assert.equal(away.text, 'young woman, long braid, linen robe')
|
||||
assert.equal(away.omittedFeatures.length, 1)
|
||||
assert.equal(away.omittedFeatures[0].text, 'brown eyes')
|
||||
assert.equal(away.omittedFeatures[0].reason, 'camera')
|
||||
|
||||
const toward = compileCharacterForFacing(ada, 'toward')
|
||||
assert.equal(toward.text, 'young woman, brown eyes, linen robe')
|
||||
assert.ok(toward.omittedFeatures.some(item => item.text === 'long braid'))
|
||||
|
||||
const hands = compileCharacterForFacing(ada, 'hands')
|
||||
assert.equal(hands.text, 'young woman, linen robe')
|
||||
assert.ok(hands.omittedFeatures.some(item => item.text === 'brown eyes'))
|
||||
|
||||
const out = compileCharacterForFacing(ada, 'out')
|
||||
assert.equal(out.text, '')
|
||||
assert.equal(out.omittedFeatures.length, 3)
|
||||
|
||||
const set = {
|
||||
...emptySet(),
|
||||
id: 's1',
|
||||
name: 'Hall',
|
||||
framings: [{ ...emptyFraming('away'), id: 'f-away', text: 'stone hall', facing: 'away' }]
|
||||
}
|
||||
const sceneText = assembleSceneText({
|
||||
style: '',
|
||||
settings: '',
|
||||
setStates: [{ setId: 's1', on: true, framingId: 'f-away' }],
|
||||
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
|
||||
prompt: 'walks'
|
||||
}, [ada], [set])
|
||||
assert.equal(sceneText, 'stone hall\n\nyoung woman, long braid, linen robe\n\nwalks')
|
||||
assert.ok(!sceneText.includes('brown eyes'))
|
||||
assert.ok(!sceneText.includes('Hall'))
|
||||
})
|
||||
|
||||
+270
-36
@@ -4,10 +4,60 @@ export type PromptSequenceOutfit = {
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Camera facing for a set framing — drives which character features compile. */
|
||||
export const PROMPT_SEQUENCE_FACINGS = [
|
||||
'toward',
|
||||
'three-quarter',
|
||||
'profile',
|
||||
'away',
|
||||
'hands',
|
||||
'out'
|
||||
] as const
|
||||
export type PromptSequenceFacing = (typeof PROMPT_SEQUENCE_FACINGS)[number]
|
||||
export type PromptSequenceVisibility = PromptSequenceFacing | 'any'
|
||||
|
||||
export const PROMPT_SEQUENCE_FEATURE_GROUPS = [
|
||||
'eyes',
|
||||
'expression',
|
||||
'face detail',
|
||||
'profile',
|
||||
'hair front',
|
||||
'hair back',
|
||||
'clothing',
|
||||
'worn accessory',
|
||||
'back only',
|
||||
'handheld'
|
||||
] as const
|
||||
export type PromptSequenceFeatureGroup = (typeof PROMPT_SEQUENCE_FEATURE_GROUPS)[number]
|
||||
|
||||
export const DEFAULT_FEATURE_VISIBILITY: Record<PromptSequenceFeatureGroup, PromptSequenceVisibility[]> = {
|
||||
eyes: ['toward', 'three-quarter'],
|
||||
expression: ['toward', 'three-quarter'],
|
||||
'face detail': ['toward', 'three-quarter'],
|
||||
profile: ['profile', 'three-quarter'],
|
||||
'hair front': ['toward', 'three-quarter'],
|
||||
'hair back': ['away', 'three-quarter'],
|
||||
clothing: ['any'],
|
||||
'worn accessory': ['any'],
|
||||
'back only': ['away', 'three-quarter'],
|
||||
handheld: ['hands', 'toward', 'three-quarter']
|
||||
}
|
||||
|
||||
export type PromptSequenceFeature = {
|
||||
id: string
|
||||
text: string
|
||||
enabled: boolean
|
||||
group: PromptSequenceFeatureGroup
|
||||
visibility: PromptSequenceVisibility[]
|
||||
}
|
||||
|
||||
export type PromptSequenceCharacter = {
|
||||
id: string
|
||||
/** Label only — never copied into the prompt. */
|
||||
name: string
|
||||
/** Base description; always included when in frame (facing !== out). */
|
||||
description: string
|
||||
features: PromptSequenceFeature[]
|
||||
outfits: PromptSequenceOutfit[]
|
||||
}
|
||||
|
||||
@@ -25,6 +75,7 @@ export type PromptSequenceFraming = {
|
||||
id: string
|
||||
label: string
|
||||
text: string
|
||||
facing: PromptSequenceFacing
|
||||
}
|
||||
|
||||
export type PromptSequenceSet = {
|
||||
@@ -104,18 +155,195 @@ export function emptyOutfit(): PromptSequenceOutfit {
|
||||
return { id: newId(), name: '', description: '' }
|
||||
}
|
||||
|
||||
export function defaultVisibilityForGroup(group: string): PromptSequenceVisibility[] {
|
||||
const key = String(group || '').trim() as PromptSequenceFeatureGroup
|
||||
const defaults = DEFAULT_FEATURE_VISIBILITY[key]
|
||||
return defaults ? [...defaults] : [...DEFAULT_FEATURE_VISIBILITY.clothing]
|
||||
}
|
||||
|
||||
export function emptyFeature(group: PromptSequenceFeatureGroup = 'clothing'): PromptSequenceFeature {
|
||||
return {
|
||||
id: newId(),
|
||||
text: '',
|
||||
enabled: true,
|
||||
group,
|
||||
visibility: defaultVisibilityForGroup(group)
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyCharacter(): PromptSequenceCharacter {
|
||||
return { id: newId(), name: '', description: '', outfits: [] }
|
||||
return { id: newId(), name: '', description: '', features: [], outfits: [] }
|
||||
}
|
||||
|
||||
export function emptyFraming(label = ''): PromptSequenceFraming {
|
||||
return { id: newId(), label, text: '' }
|
||||
return { id: newId(), label, text: '', facing: 'toward' }
|
||||
}
|
||||
|
||||
export function emptySet(): PromptSequenceSet {
|
||||
return { id: newId(), name: '', framings: [emptyFraming()] }
|
||||
}
|
||||
|
||||
/** Editor-only spans: !brown eyes! → stripped at compile. */
|
||||
export function stripBangs(text: string) {
|
||||
return String(text || '')
|
||||
.replace(/!([^!\n]+)!/g, ' ')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.replace(/\s+([,.])/g, '$1')
|
||||
.replace(/,\s*,+/g, ',')
|
||||
.replace(/^,\s*/, '')
|
||||
.replace(/,\s*$/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function extractBangPhrases(text: string) {
|
||||
const out: string[] = []
|
||||
const re = /!([^!\n]+)!/g
|
||||
let match: RegExpExecArray | null
|
||||
const source = String(text || '')
|
||||
while ((match = re.exec(source))) {
|
||||
const phrase = match[1].trim()
|
||||
if (phrase) out.push(phrase)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function normalizeFacing(raw: unknown): PromptSequenceFacing {
|
||||
const value = String(raw || '').trim()
|
||||
return (PROMPT_SEQUENCE_FACINGS as readonly string[]).includes(value)
|
||||
? (value as PromptSequenceFacing)
|
||||
: 'toward'
|
||||
}
|
||||
|
||||
export function normalizeFeatureGroup(raw: unknown): PromptSequenceFeatureGroup {
|
||||
const value = String(raw || '').trim()
|
||||
return (PROMPT_SEQUENCE_FEATURE_GROUPS as readonly string[]).includes(value)
|
||||
? (value as PromptSequenceFeatureGroup)
|
||||
: 'clothing'
|
||||
}
|
||||
|
||||
export function normalizeVisibilityList(raw: unknown, group: PromptSequenceFeatureGroup): PromptSequenceVisibility[] {
|
||||
const allowed = new Set<string>([...PROMPT_SEQUENCE_FACINGS, 'any'])
|
||||
if (!Array.isArray(raw) || !raw.length) return defaultVisibilityForGroup(group)
|
||||
const seen = new Set<PromptSequenceVisibility>()
|
||||
const out: PromptSequenceVisibility[] = []
|
||||
for (const item of raw) {
|
||||
const key = String(item || '').trim() as PromptSequenceVisibility
|
||||
if (!allowed.has(key) || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(key)
|
||||
}
|
||||
return out.length ? out : defaultVisibilityForGroup(group)
|
||||
}
|
||||
|
||||
/** Facing from the first on set that has a selected framing; default toward. */
|
||||
export function resolveSceneFacing(
|
||||
scene: { setStates?: PromptSequenceSetState[] },
|
||||
sets: PromptSequenceSet[] = []
|
||||
): PromptSequenceFacing {
|
||||
const states = scene.setStates || []
|
||||
const stateById = new Map(states.map(state => [state.setId, state]))
|
||||
for (const set of sets) {
|
||||
const state = stateById.get(set.id)
|
||||
if (state && !state.on) continue
|
||||
const framingId = state?.framingId || null
|
||||
if (!framingId) continue
|
||||
const framing = (set.framings || []).find(item => item.id === framingId)
|
||||
if (framing) return normalizeFacing(framing.facing)
|
||||
}
|
||||
return 'toward'
|
||||
}
|
||||
|
||||
export function isFeatureVisibleForFacing(
|
||||
feature: { enabled?: boolean; group?: string; visibility?: PromptSequenceVisibility[] },
|
||||
facing: PromptSequenceFacing
|
||||
) {
|
||||
if (!feature?.enabled) return false
|
||||
if (facing === 'out') return false
|
||||
const visibility = Array.isArray(feature.visibility) ? feature.visibility : []
|
||||
if (visibility.includes(facing)) return true
|
||||
if (visibility.includes('any')) {
|
||||
if (facing === 'hands') {
|
||||
return feature.group === 'handheld' || feature.group === 'clothing'
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function outfitPromptText(outfit?: { name?: string; description?: string } | null) {
|
||||
if (!outfit) return ''
|
||||
return String(outfit.description || '').trim() || String(outfit.name || '').trim()
|
||||
}
|
||||
|
||||
export type CompiledCharacterFacing = {
|
||||
text: string
|
||||
omittedFeatures: Array<{ id: string; text: string; reason: 'camera' }>
|
||||
ignoredBangs: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile one character for a camera facing.
|
||||
* Name is never copied. Facing "out" emits nothing.
|
||||
*/
|
||||
export function compileCharacterForFacing(
|
||||
character: {
|
||||
id?: string
|
||||
name?: string
|
||||
description?: string
|
||||
features?: PromptSequenceFeature[]
|
||||
outfits?: PromptSequenceOutfit[]
|
||||
},
|
||||
facing: PromptSequenceFacing,
|
||||
outfit?: { name?: string; description?: string } | null
|
||||
): CompiledCharacterFacing {
|
||||
const ignoredBangs = [
|
||||
...extractBangPhrases(String(character?.description || '')),
|
||||
...(character?.features || []).flatMap(feature => extractBangPhrases(String(feature?.text || ''))),
|
||||
...extractBangPhrases(String(outfit?.description || '')),
|
||||
...extractBangPhrases(String(outfit?.name || ''))
|
||||
]
|
||||
const omittedFeatures: CompiledCharacterFacing['omittedFeatures'] = []
|
||||
const features = character?.features || []
|
||||
|
||||
if (facing === 'out') {
|
||||
for (const feature of features) {
|
||||
if (!feature?.enabled) continue
|
||||
const text = stripBangs(String(feature.text || ''))
|
||||
omittedFeatures.push({
|
||||
id: String(feature.id || newId()),
|
||||
text: text || String(feature.text || '').trim(),
|
||||
reason: 'camera'
|
||||
})
|
||||
}
|
||||
return { text: '', omittedFeatures, ignoredBangs }
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
const base = stripBangs(String(character?.description || ''))
|
||||
if (base) parts.push(base)
|
||||
|
||||
for (const feature of features) {
|
||||
if (!feature?.enabled) continue
|
||||
const cleaned = stripBangs(String(feature.text || ''))
|
||||
if (isFeatureVisibleForFacing(feature, facing)) {
|
||||
if (cleaned) parts.push(cleaned)
|
||||
} else {
|
||||
omittedFeatures.push({
|
||||
id: String(feature.id || newId()),
|
||||
text: cleaned || String(feature.text || '').trim(),
|
||||
reason: 'camera'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let text = parts.join(', ')
|
||||
const outfitText = stripBangs(outfitPromptText(outfit))
|
||||
if (outfitText) {
|
||||
text = text ? `${text}. Outfit: ${outfitText}` : outfitText
|
||||
}
|
||||
return { text, omittedFeatures, ignoredBangs }
|
||||
}
|
||||
|
||||
export function defaultCharacterState(characterId: string, on = true): PromptSequenceCharacterState {
|
||||
return { characterId, on, outfitId: null }
|
||||
}
|
||||
@@ -160,32 +388,19 @@ export function cloneSceneForNext(previous: PromptSequenceScene, nextIndex: numb
|
||||
}
|
||||
}
|
||||
|
||||
function outfitPromptText(outfit?: { name?: string; description?: string } | null) {
|
||||
if (!outfit) return ''
|
||||
return String(outfit.description || '').trim() || String(outfit.name || '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Character line when on:
|
||||
* - with outfit: "Name: description. Outfit: outfit description"
|
||||
* - without: "Name: description" (or name-only / description-only)
|
||||
* Legacy helper: base description (+ optional outfit). Name is never copied.
|
||||
* Prefer compileCharacterForFacing for feature-aware compile.
|
||||
*/
|
||||
export function formatCharacterLine(
|
||||
character: { name?: string; description?: string },
|
||||
outfit?: { name?: string; description?: string } | null
|
||||
character: { name?: string; description?: string; features?: PromptSequenceFeature[] },
|
||||
outfit?: { name?: string; description?: string } | null,
|
||||
facing: PromptSequenceFacing = 'toward'
|
||||
) {
|
||||
const name = String(character?.name || '').trim()
|
||||
const description = String(character?.description || '').trim()
|
||||
let base = ''
|
||||
if (name && description) base = `${name}: ${description}`
|
||||
else base = name || description
|
||||
if (!base) return ''
|
||||
const outfitText = outfitPromptText(outfit)
|
||||
if (!outfitText) return base
|
||||
return `${base}. Outfit: ${outfitText}`
|
||||
return compileCharacterForFacing(character, facing, outfit).text
|
||||
}
|
||||
|
||||
/** Selected framing text for each on set. No set name, no framing label. */
|
||||
/** Selected framing text for each on set. No set name, no framing label. Bangs stripped. */
|
||||
export function assembleSetBlock(
|
||||
scene: { setStates?: PromptSequenceSetState[] },
|
||||
sets: PromptSequenceSet[] = []
|
||||
@@ -199,16 +414,18 @@ export function assembleSetBlock(
|
||||
const framingId = state?.framingId || null
|
||||
if (!framingId) continue
|
||||
const framing = (set.framings || []).find(item => item.id === framingId)
|
||||
const text = String(framing?.text || '').trim()
|
||||
const text = stripBangs(String(framing?.text || ''))
|
||||
if (text) chunks.push(text)
|
||||
}
|
||||
return chunks.join('\n')
|
||||
}
|
||||
|
||||
export function resolveSceneCharacterLines(
|
||||
scene: { characterStates?: PromptSequenceCharacterState[] },
|
||||
characters: PromptSequenceCharacter[] = []
|
||||
scene: { setStates?: PromptSequenceSetState[]; characterStates?: PromptSequenceCharacterState[] },
|
||||
characters: PromptSequenceCharacter[] = [],
|
||||
sets: PromptSequenceSet[] = []
|
||||
) {
|
||||
const facing = resolveSceneFacing(scene, sets)
|
||||
const states = scene.characterStates || []
|
||||
const stateById = new Map(states.map(state => [state.characterId, state]))
|
||||
return characters
|
||||
@@ -219,7 +436,7 @@ export function resolveSceneCharacterLines(
|
||||
const outfit = outfitId
|
||||
? (character.outfits || []).find(item => item.id === outfitId) || null
|
||||
: null
|
||||
return formatCharacterLine(character, outfit)
|
||||
return compileCharacterForFacing(character, facing, outfit).text
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
@@ -232,27 +449,29 @@ export function assembleSceneText(
|
||||
setStates?: PromptSequenceSetState[]
|
||||
characterStates?: PromptSequenceCharacterState[]
|
||||
/** Legacy per-scene characters (migration / old tests). */
|
||||
characters?: Array<{ name?: string; description?: string }>
|
||||
characters?: Array<{ name?: string; description?: string; features?: PromptSequenceFeature[] }>
|
||||
},
|
||||
sequenceCharacters?: PromptSequenceCharacter[],
|
||||
sequenceSets?: PromptSequenceSet[],
|
||||
sectionOrder?: PromptSequenceSectionKey[] | unknown
|
||||
) {
|
||||
const setBlock = sequenceSets?.length
|
||||
? assembleSetBlock(scene, sequenceSets)
|
||||
const sets = sequenceSets || []
|
||||
const setBlock = sets.length
|
||||
? assembleSetBlock(scene, sets)
|
||||
: ''
|
||||
const facing = resolveSceneFacing(scene, sets)
|
||||
const characterBlock = sequenceCharacters?.length
|
||||
? resolveSceneCharacterLines(scene, sequenceCharacters).join('\n')
|
||||
? resolveSceneCharacterLines(scene, sequenceCharacters, sets).join('\n')
|
||||
: (scene.characters || [])
|
||||
.map(character => formatCharacterLine(character))
|
||||
.map(character => compileCharacterForFacing(character, facing).text)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
const blocks: Record<PromptSequenceSectionKey, string> = {
|
||||
style: String(scene.style || '').trim(),
|
||||
settings: String(scene.settings || '').trim(),
|
||||
style: stripBangs(String(scene.style || '')),
|
||||
settings: stripBangs(String(scene.settings || '')),
|
||||
set: setBlock,
|
||||
characters: characterBlock,
|
||||
prompt: String(scene.prompt || '').trim()
|
||||
prompt: stripBangs(String(scene.prompt || ''))
|
||||
}
|
||||
const order = normalizeSectionOrder(sectionOrder)
|
||||
const parts = order.map(key => blocks[key]).filter(Boolean)
|
||||
@@ -313,13 +532,27 @@ export function normalizeOutfit(raw: unknown): PromptSequenceOutfit {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeFeature(raw: unknown): PromptSequenceFeature {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const group = normalizeFeatureGroup(rec.group)
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
text: normalizePromptSequenceField(rec.text),
|
||||
enabled: rec.enabled === undefined ? true : !!rec.enabled,
|
||||
group,
|
||||
visibility: normalizeVisibilityList(rec.visibility, group)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCharacter(raw: unknown): PromptSequenceCharacter {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const outfits = Array.isArray(rec.outfits) ? rec.outfits.map(normalizeOutfit) : []
|
||||
const features = Array.isArray(rec.features) ? rec.features.map(normalizeFeature) : []
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
name: String(rec.name || '').trim().slice(0, 120),
|
||||
description: normalizePromptSequenceField(rec.description).trim(),
|
||||
features,
|
||||
outfits
|
||||
}
|
||||
}
|
||||
@@ -329,7 +562,8 @@ export function normalizeFraming(raw: unknown): PromptSequenceFraming {
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
label: String(rec.label || '').trim().slice(0, 120),
|
||||
text: normalizePromptSequenceField(rec.text)
|
||||
text: normalizePromptSequenceField(rec.text),
|
||||
facing: normalizeFacing(rec.facing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +579,7 @@ function framingsFromLegacyBeats(rec: Record<string, unknown>): PromptSequenceFr
|
||||
if (text) parts.push(text)
|
||||
}
|
||||
if (!parts.length) return [emptyFraming()]
|
||||
return [{ id: newId(), label: 'wide', text: parts.join('\n') }]
|
||||
return [{ id: newId(), label: 'wide', text: parts.join('\n'), facing: 'toward' }]
|
||||
}
|
||||
|
||||
export function normalizeSet(raw: unknown): PromptSequenceSet {
|
||||
|
||||
Reference in New Issue
Block a user