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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="block text-sm">
|
||||||
<input
|
<input
|
||||||
v-model="character.name"
|
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"
|
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"
|
maxlength="120"
|
||||||
placeholder="Name"
|
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
|
<textarea
|
||||||
v-model="character.description"
|
v-model="character.description"
|
||||||
rows="2"
|
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"
|
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="space-y-2 border-t border-white/5 pt-3">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<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>
|
<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"
|
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"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -458,6 +558,25 @@
|
|||||||
v-if="previewOpen"
|
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]"
|
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>
|
>{{ 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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -507,25 +626,37 @@
|
|||||||
import type {
|
import type {
|
||||||
PromptSequence,
|
PromptSequence,
|
||||||
PromptSequenceCharacter,
|
PromptSequenceCharacter,
|
||||||
|
PromptSequenceFacing,
|
||||||
|
PromptSequenceFeature,
|
||||||
|
PromptSequenceFraming,
|
||||||
PromptSequenceScene,
|
PromptSequenceScene,
|
||||||
PromptSequenceSectionKey,
|
PromptSequenceSectionKey,
|
||||||
PromptSequenceSet
|
PromptSequenceSet
|
||||||
} from '~/utils/promptSequences'
|
} from '~/utils/promptSequences'
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_FEATURE_VISIBILITY,
|
||||||
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||||
|
PROMPT_SEQUENCE_FACINGS,
|
||||||
|
PROMPT_SEQUENCE_FEATURE_GROUPS,
|
||||||
PROMPT_SEQUENCE_FRAMING_SUGGESTIONS,
|
PROMPT_SEQUENCE_FRAMING_SUGGESTIONS,
|
||||||
assembleSceneText,
|
assembleSceneText,
|
||||||
assembleSequenceText,
|
assembleSequenceText,
|
||||||
cloneSceneForNext,
|
cloneSceneForNext,
|
||||||
|
compileCharacterForFacing,
|
||||||
|
defaultVisibilityForGroup,
|
||||||
emptyCharacter,
|
emptyCharacter,
|
||||||
|
emptyFeature,
|
||||||
emptyFraming,
|
emptyFraming,
|
||||||
emptyOutfit,
|
emptyOutfit,
|
||||||
emptyScene,
|
emptyScene,
|
||||||
emptySet,
|
emptySet,
|
||||||
ensureSceneStatesForCharacters,
|
ensureSceneStatesForCharacters,
|
||||||
ensureSceneStatesForSets,
|
ensureSceneStatesForSets,
|
||||||
|
extractBangPhrases,
|
||||||
normalizePromptSequence,
|
normalizePromptSequence,
|
||||||
normalizeSectionOrder
|
normalizeSectionOrder,
|
||||||
|
resolveSceneFacing,
|
||||||
|
stripBangs
|
||||||
} from '~/utils/promptSequences'
|
} from '~/utils/promptSequences'
|
||||||
|
|
||||||
useHead({ title: 'Prompt Builder' })
|
useHead({ title: 'Prompt Builder' })
|
||||||
@@ -538,6 +669,10 @@ const SECTION_LABELS: Record<PromptSequenceSectionKey, string> = {
|
|||||||
prompt: 'Prompt'
|
prompt: 'Prompt'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VisibilityChipKey = PromptSequenceFacing | 'any'
|
||||||
|
|
||||||
|
const visibilityChipKeys: VisibilityChipKey[] = [...PROMPT_SEQUENCE_FACINGS, 'any']
|
||||||
|
|
||||||
const sequences = ref<PromptSequence[]>([])
|
const sequences = ref<PromptSequence[]>([])
|
||||||
const selectedId = ref('')
|
const selectedId = ref('')
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
@@ -568,6 +703,72 @@ const activeSceneText = computed(() => {
|
|||||||
return assembleSceneText(activeScene.value, characters.value, sets.value, sectionOrder.value)
|
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) {
|
function sectionLabel(key: PromptSequenceSectionKey) {
|
||||||
return SECTION_LABELS[key] || key
|
return SECTION_LABELS[key] || key
|
||||||
}
|
}
|
||||||
@@ -628,12 +829,29 @@ function select(sequence: PromptSequence) {
|
|||||||
characters.value = (normalized.characters?.length ? normalized.characters : [emptyCharacter()]).map(character => ({
|
characters.value = (normalized.characters?.length ? normalized.characters : [emptyCharacter()]).map(character => ({
|
||||||
...emptyCharacter(),
|
...emptyCharacter(),
|
||||||
...character,
|
...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 }))
|
outfits: (character.outfits || []).map(outfit => ({ ...emptyOutfit(), ...outfit }))
|
||||||
}))
|
}))
|
||||||
sets.value = (normalized.sets || []).map(set => ({
|
sets.value = (normalized.sets || []).map(set => ({
|
||||||
...emptySet(),
|
...emptySet(),
|
||||||
...set,
|
...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) => ({
|
scenes.value = (normalized.scenes?.length ? normalized.scenes : [emptyScene(1, characters.value, sets.value)]).map((scene, index) => ({
|
||||||
...emptyScene(index + 1, characters.value, sets.value),
|
...emptyScene(index + 1, characters.value, sets.value),
|
||||||
@@ -702,6 +920,7 @@ function clearCharacter(index: number) {
|
|||||||
if (!character) return
|
if (!character) return
|
||||||
character.name = ''
|
character.name = ''
|
||||||
character.description = ''
|
character.description = ''
|
||||||
|
character.features = []
|
||||||
character.outfits = []
|
character.outfits = []
|
||||||
for (const scene of scenes.value) {
|
for (const scene of scenes.value) {
|
||||||
const state = scene.characterStates.find(item => item.characterId === character.id)
|
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) {
|
function removeCharacter(index: number) {
|
||||||
if (characters.value.length <= 1) return
|
if (characters.value.length <= 1) return
|
||||||
const [removed] = characters.value.splice(index, 1)
|
const [removed] = characters.value.splice(index, 1)
|
||||||
@@ -785,6 +1058,7 @@ function clearFraming(setIndex: number, framingIndex: number) {
|
|||||||
if (!framing) return
|
if (!framing) return
|
||||||
framing.label = ''
|
framing.label = ''
|
||||||
framing.text = ''
|
framing.text = ''
|
||||||
|
framing.facing = 'toward'
|
||||||
}
|
}
|
||||||
|
|
||||||
function sceneSetState(setId: string) {
|
function sceneSetState(setId: string) {
|
||||||
|
|||||||
@@ -24,22 +24,33 @@ const {
|
|||||||
emptyOutfit,
|
emptyOutfit,
|
||||||
emptySet,
|
emptySet,
|
||||||
emptyFraming,
|
emptyFraming,
|
||||||
|
emptyFeature,
|
||||||
cloneSceneForNext,
|
cloneSceneForNext,
|
||||||
normalizePromptSequence,
|
normalizePromptSequence,
|
||||||
normalizeSectionOrder,
|
normalizeSectionOrder,
|
||||||
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||||
ensureSceneStatesForCharacters
|
ensureSceneStatesForCharacters,
|
||||||
|
stripBangs,
|
||||||
|
extractBangPhrases,
|
||||||
|
compileCharacterForFacing,
|
||||||
|
defaultVisibilityForGroup
|
||||||
} = await import(pathToFileURL(tmp).href)
|
} = await import(pathToFileURL(tmp).href)
|
||||||
unlinkSync(tmp)
|
unlinkSync(tmp)
|
||||||
|
|
||||||
test('formatCharacterLine covers name/description and outfit', () => {
|
test('formatCharacterLine copies base and outfit, never name', () => {
|
||||||
assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'Ada: tall')
|
assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'tall')
|
||||||
assert.equal(
|
assert.equal(
|
||||||
formatCharacterLine({ name: 'Ada', description: 'tall' }, { name: 'Work', description: 'blue jacket' }),
|
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', () => {
|
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 wide = { ...emptyFraming('wide'), id: 'f-wide', text: 'open courtyard at dawn' }
|
||||||
const medium = { ...emptyFraming('medium'), id: 'f-med', text: 'courtyard from the arch' }
|
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.length, 1)
|
||||||
assert.equal(legacy.sets[0].framings.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].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('stone walls'))
|
||||||
assert.ok(legacy.sets[0].framings[0].text.includes('first beat'))
|
assert.ok(legacy.sets[0].framings[0].text.includes('first beat'))
|
||||||
assert.ok(legacy.sets[0].framings[0].text.includes('second 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
|
updatedAt: 1
|
||||||
})
|
})
|
||||||
assert.deepEqual(legacy.sets, [])
|
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', () => {
|
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' }],
|
characterStates: [{ characterId: 'c1', on: true, outfitId: 'o1' }],
|
||||||
prompt: 'hi'
|
prompt: 'hi'
|
||||||
}, [ada], [])
|
}, [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', () => {
|
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 order = ['settings', 'prompt', 'characters']
|
||||||
const text = assembleSceneText(scene, [ada], [set], order)
|
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('open yard'))
|
||||||
assert.ok(!text.includes('Courtyard'))
|
assert.ok(!text.includes('Courtyard'))
|
||||||
assert.ok(!text.includes('wide'))
|
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)
|
const full = assembleSequenceText(legacy)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
full,
|
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('Hall'))
|
||||||
assert.ok(!full.includes('wide'))
|
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
|
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 = {
|
export type PromptSequenceCharacter = {
|
||||||
id: string
|
id: string
|
||||||
|
/** Label only — never copied into the prompt. */
|
||||||
name: string
|
name: string
|
||||||
|
/** Base description; always included when in frame (facing !== out). */
|
||||||
description: string
|
description: string
|
||||||
|
features: PromptSequenceFeature[]
|
||||||
outfits: PromptSequenceOutfit[]
|
outfits: PromptSequenceOutfit[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +75,7 @@ export type PromptSequenceFraming = {
|
|||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
text: string
|
text: string
|
||||||
|
facing: PromptSequenceFacing
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptSequenceSet = {
|
export type PromptSequenceSet = {
|
||||||
@@ -104,18 +155,195 @@ export function emptyOutfit(): PromptSequenceOutfit {
|
|||||||
return { id: newId(), name: '', description: '' }
|
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 {
|
export function emptyCharacter(): PromptSequenceCharacter {
|
||||||
return { id: newId(), name: '', description: '', outfits: [] }
|
return { id: newId(), name: '', description: '', features: [], outfits: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyFraming(label = ''): PromptSequenceFraming {
|
export function emptyFraming(label = ''): PromptSequenceFraming {
|
||||||
return { id: newId(), label, text: '' }
|
return { id: newId(), label, text: '', facing: 'toward' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptySet(): PromptSequenceSet {
|
export function emptySet(): PromptSequenceSet {
|
||||||
return { id: newId(), name: '', framings: [emptyFraming()] }
|
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 {
|
export function defaultCharacterState(characterId: string, on = true): PromptSequenceCharacterState {
|
||||||
return { characterId, on, outfitId: null }
|
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:
|
* Legacy helper: base description (+ optional outfit). Name is never copied.
|
||||||
* - with outfit: "Name: description. Outfit: outfit description"
|
* Prefer compileCharacterForFacing for feature-aware compile.
|
||||||
* - without: "Name: description" (or name-only / description-only)
|
|
||||||
*/
|
*/
|
||||||
export function formatCharacterLine(
|
export function formatCharacterLine(
|
||||||
character: { name?: string; description?: string },
|
character: { name?: string; description?: string; features?: PromptSequenceFeature[] },
|
||||||
outfit?: { name?: string; description?: string } | null
|
outfit?: { name?: string; description?: string } | null,
|
||||||
|
facing: PromptSequenceFacing = 'toward'
|
||||||
) {
|
) {
|
||||||
const name = String(character?.name || '').trim()
|
return compileCharacterForFacing(character, facing, outfit).text
|
||||||
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}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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(
|
export function assembleSetBlock(
|
||||||
scene: { setStates?: PromptSequenceSetState[] },
|
scene: { setStates?: PromptSequenceSetState[] },
|
||||||
sets: PromptSequenceSet[] = []
|
sets: PromptSequenceSet[] = []
|
||||||
@@ -199,16 +414,18 @@ export function assembleSetBlock(
|
|||||||
const framingId = state?.framingId || null
|
const framingId = state?.framingId || null
|
||||||
if (!framingId) continue
|
if (!framingId) continue
|
||||||
const framing = (set.framings || []).find(item => item.id === framingId)
|
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)
|
if (text) chunks.push(text)
|
||||||
}
|
}
|
||||||
return chunks.join('\n')
|
return chunks.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSceneCharacterLines(
|
export function resolveSceneCharacterLines(
|
||||||
scene: { characterStates?: PromptSequenceCharacterState[] },
|
scene: { setStates?: PromptSequenceSetState[]; characterStates?: PromptSequenceCharacterState[] },
|
||||||
characters: PromptSequenceCharacter[] = []
|
characters: PromptSequenceCharacter[] = [],
|
||||||
|
sets: PromptSequenceSet[] = []
|
||||||
) {
|
) {
|
||||||
|
const facing = resolveSceneFacing(scene, sets)
|
||||||
const states = scene.characterStates || []
|
const states = scene.characterStates || []
|
||||||
const stateById = new Map(states.map(state => [state.characterId, state]))
|
const stateById = new Map(states.map(state => [state.characterId, state]))
|
||||||
return characters
|
return characters
|
||||||
@@ -219,7 +436,7 @@ export function resolveSceneCharacterLines(
|
|||||||
const outfit = outfitId
|
const outfit = outfitId
|
||||||
? (character.outfits || []).find(item => item.id === outfitId) || null
|
? (character.outfits || []).find(item => item.id === outfitId) || null
|
||||||
: null
|
: null
|
||||||
return formatCharacterLine(character, outfit)
|
return compileCharacterForFacing(character, facing, outfit).text
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
@@ -232,27 +449,29 @@ export function assembleSceneText(
|
|||||||
setStates?: PromptSequenceSetState[]
|
setStates?: PromptSequenceSetState[]
|
||||||
characterStates?: PromptSequenceCharacterState[]
|
characterStates?: PromptSequenceCharacterState[]
|
||||||
/** Legacy per-scene characters (migration / old tests). */
|
/** Legacy per-scene characters (migration / old tests). */
|
||||||
characters?: Array<{ name?: string; description?: string }>
|
characters?: Array<{ name?: string; description?: string; features?: PromptSequenceFeature[] }>
|
||||||
},
|
},
|
||||||
sequenceCharacters?: PromptSequenceCharacter[],
|
sequenceCharacters?: PromptSequenceCharacter[],
|
||||||
sequenceSets?: PromptSequenceSet[],
|
sequenceSets?: PromptSequenceSet[],
|
||||||
sectionOrder?: PromptSequenceSectionKey[] | unknown
|
sectionOrder?: PromptSequenceSectionKey[] | unknown
|
||||||
) {
|
) {
|
||||||
const setBlock = sequenceSets?.length
|
const sets = sequenceSets || []
|
||||||
? assembleSetBlock(scene, sequenceSets)
|
const setBlock = sets.length
|
||||||
|
? assembleSetBlock(scene, sets)
|
||||||
: ''
|
: ''
|
||||||
|
const facing = resolveSceneFacing(scene, sets)
|
||||||
const characterBlock = sequenceCharacters?.length
|
const characterBlock = sequenceCharacters?.length
|
||||||
? resolveSceneCharacterLines(scene, sequenceCharacters).join('\n')
|
? resolveSceneCharacterLines(scene, sequenceCharacters, sets).join('\n')
|
||||||
: (scene.characters || [])
|
: (scene.characters || [])
|
||||||
.map(character => formatCharacterLine(character))
|
.map(character => compileCharacterForFacing(character, facing).text)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
const blocks: Record<PromptSequenceSectionKey, string> = {
|
const blocks: Record<PromptSequenceSectionKey, string> = {
|
||||||
style: String(scene.style || '').trim(),
|
style: stripBangs(String(scene.style || '')),
|
||||||
settings: String(scene.settings || '').trim(),
|
settings: stripBangs(String(scene.settings || '')),
|
||||||
set: setBlock,
|
set: setBlock,
|
||||||
characters: characterBlock,
|
characters: characterBlock,
|
||||||
prompt: String(scene.prompt || '').trim()
|
prompt: stripBangs(String(scene.prompt || ''))
|
||||||
}
|
}
|
||||||
const order = normalizeSectionOrder(sectionOrder)
|
const order = normalizeSectionOrder(sectionOrder)
|
||||||
const parts = order.map(key => blocks[key]).filter(Boolean)
|
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 {
|
export function normalizeCharacter(raw: unknown): PromptSequenceCharacter {
|
||||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||||
const outfits = Array.isArray(rec.outfits) ? rec.outfits.map(normalizeOutfit) : []
|
const outfits = Array.isArray(rec.outfits) ? rec.outfits.map(normalizeOutfit) : []
|
||||||
|
const features = Array.isArray(rec.features) ? rec.features.map(normalizeFeature) : []
|
||||||
return {
|
return {
|
||||||
id: String(rec.id || '').trim() || newId(),
|
id: String(rec.id || '').trim() || newId(),
|
||||||
name: String(rec.name || '').trim().slice(0, 120),
|
name: String(rec.name || '').trim().slice(0, 120),
|
||||||
description: normalizePromptSequenceField(rec.description).trim(),
|
description: normalizePromptSequenceField(rec.description).trim(),
|
||||||
|
features,
|
||||||
outfits
|
outfits
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,7 +562,8 @@ export function normalizeFraming(raw: unknown): PromptSequenceFraming {
|
|||||||
return {
|
return {
|
||||||
id: String(rec.id || '').trim() || newId(),
|
id: String(rec.id || '').trim() || newId(),
|
||||||
label: String(rec.label || '').trim().slice(0, 120),
|
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 (text) parts.push(text)
|
||||||
}
|
}
|
||||||
if (!parts.length) return [emptyFraming()]
|
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 {
|
export function normalizeSet(raw: unknown): PromptSequenceSet {
|
||||||
|
|||||||
Reference in New Issue
Block a user