@nodaro/prompts 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/README.md +27 -0
- package/dist/index.cjs +21240 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3599 -0
- package/dist/index.d.ts +3599 -0
- package/dist/index.js +20854 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/__tests__/__snapshots__/prompt-builder-segments.test.ts.snap +65 -0
- package/src/__tests__/action-fx.test.ts +155 -0
- package/src/__tests__/apply-picker-json.test.ts +50 -0
- package/src/__tests__/assemble-image-input.test.ts +248 -0
- package/src/__tests__/assemble-suno-input.test.ts +283 -0
- package/src/__tests__/brand-tokens.test.ts +81 -0
- package/src/__tests__/build-image-prompt-element-injection.test.ts +118 -0
- package/src/__tests__/build-image-prompt-hybrid-format.test.ts +131 -0
- package/src/__tests__/build-image-prompt-mentions.test.ts +809 -0
- package/src/__tests__/build-image-prompt-reference-cap.test.ts +57 -0
- package/src/__tests__/build-image-prompt-reference-numbering.test.ts +237 -0
- package/src/__tests__/build-image-prompt-reference-order.test.ts +313 -0
- package/src/__tests__/camera-motions-from-connections.test.ts +71 -0
- package/src/__tests__/catalog-gapfill.test.ts +53 -0
- package/src/__tests__/character-convergence-image.test.ts +217 -0
- package/src/__tests__/character-default-role-image.test.ts +167 -0
- package/src/__tests__/character-default-role-video.test.ts +168 -0
- package/src/__tests__/character-default-role.test.ts +70 -0
- package/src/__tests__/character-fx.test.ts +200 -0
- package/src/__tests__/entity-prompts-location.test.ts +97 -0
- package/src/__tests__/entity-prompts.test.ts +240 -0
- package/src/__tests__/expand-extra-refs-role.test.ts +50 -0
- package/src/__tests__/factory-presets.test.ts +1045 -0
- package/src/__tests__/factory-snippets.test.ts +68 -0
- package/src/__tests__/framing-multi.test.ts +71 -0
- package/src/__tests__/framing-vantage.test.ts +39 -0
- package/src/__tests__/i18n-entry-completeness.test.ts +269 -0
- package/src/__tests__/identity-lock.test.ts +46 -0
- package/src/__tests__/instrumentation.test.ts +68 -0
- package/src/__tests__/lighting-multi.test.ts +68 -0
- package/src/__tests__/location-convergence-image.test.ts +124 -0
- package/src/__tests__/mention-lock-flag.test.ts +499 -0
- package/src/__tests__/multi-picker-spec.test.ts +71 -0
- package/src/__tests__/music-genre.test.ts +103 -0
- package/src/__tests__/music-mood.test.ts +87 -0
- package/src/__tests__/object-creature-convergence-image.test.ts +105 -0
- package/src/__tests__/parameter-prompt-hint.test.ts +270 -0
- package/src/__tests__/parameter-registry-sync.test.ts +243 -0
- package/src/__tests__/person-age.test.ts +80 -0
- package/src/__tests__/person-analyzer-invariants.test.ts +47 -0
- package/src/__tests__/person-body-axes.test.ts +67 -0
- package/src/__tests__/person-facial-geometry.test.ts +156 -0
- package/src/__tests__/person-regional-aesthetic.test.ts +161 -0
- package/src/__tests__/person-sections.test.ts +18 -0
- package/src/__tests__/picker-analyzer-registry.test.ts +108 -0
- package/src/__tests__/picker-catalogs-project.test.ts +85 -0
- package/src/__tests__/picker-catalogs.test.ts +53 -0
- package/src/__tests__/picker-limits.test.ts +20 -0
- package/src/__tests__/prompt-builder-segments.test.ts +183 -0
- package/src/__tests__/prompt-builder-structured-fields.test.ts +40 -0
- package/src/__tests__/prompt-builder.test.ts +1773 -0
- package/src/__tests__/prompt-wizard-categories.test.ts +31 -0
- package/src/__tests__/provider-prompt-doctrine.test.ts +49 -0
- package/src/__tests__/resolve-prompt-append.test.ts +58 -0
- package/src/__tests__/resolve-prompt.test.ts +44 -0
- package/src/__tests__/role-picker-shared.test.ts +208 -0
- package/src/__tests__/seedance-2-inputs.test.ts +173 -0
- package/src/__tests__/seedance-extend.test.ts +44 -0
- package/src/__tests__/sound-aggregator.test.ts +350 -0
- package/src/__tests__/style-presets.test.ts +34 -0
- package/src/__tests__/temporal-multi.test.ts +74 -0
- package/src/__tests__/transitions.test.ts +213 -0
- package/src/__tests__/video-reference-features.test.ts +41 -0
- package/src/__tests__/video-reference-leading-refs.test.ts +90 -0
- package/src/__tests__/video-reference-resolver.test.ts +233 -0
- package/src/__tests__/video-reference-roles.test.ts +96 -0
- package/src/__tests__/voice-character.test.ts +48 -0
- package/src/__tests__/voice-delivery.test.ts +41 -0
- package/src/__tests__/wardrobe.test.ts +25 -0
- package/src/action-fx.ts +255 -0
- package/src/aesthetic.ts +435 -0
- package/src/assemble-image-input.ts +236 -0
- package/src/assemble-suno-input.ts +147 -0
- package/src/atmosphere.ts +104 -0
- package/src/backdrop.ts +131 -0
- package/src/brand-tokens.ts +154 -0
- package/src/camera-format.ts +76 -0
- package/src/camera-motions.ts +615 -0
- package/src/character-fx.ts +274 -0
- package/src/color-look.ts +103 -0
- package/src/composition-effects.ts +67 -0
- package/src/entity-prompts.ts +231 -0
- package/src/era.ts +292 -0
- package/src/exposure-settings.ts +142 -0
- package/src/factory-presets/generate-image.ts +1644 -0
- package/src/factory-presets/generate-video.ts +1116 -0
- package/src/factory-presets/index.ts +46 -0
- package/src/factory-presets/lottie-overlay.ts +166 -0
- package/src/factory-presets/motion-graphics.ts +350 -0
- package/src/factory-presets/music.ts +734 -0
- package/src/factory-presets/sfx.ts +136 -0
- package/src/factory-presets/shared-image.ts +207 -0
- package/src/factory-presets/switchx.ts +65 -0
- package/src/factory-presets/text.ts +292 -0
- package/src/factory-presets/types.ts +51 -0
- package/src/factory-presets/video-edit.ts +136 -0
- package/src/factory-presets/voice.ts +172 -0
- package/src/factory-presets.ts +2 -0
- package/src/factory-snippets/catalog.ts +105 -0
- package/src/factory-snippets/index.ts +16 -0
- package/src/factory-snippets/types.ts +33 -0
- package/src/framing.ts +634 -0
- package/src/held-prop.ts +187 -0
- package/src/identity-lock.ts +213 -0
- package/src/index.ts +66 -0
- package/src/instrumentation.ts +337 -0
- package/src/lens.ts +59 -0
- package/src/lighting.ts +229 -0
- package/src/loop-subject.ts +276 -0
- package/src/materials.ts +184 -0
- package/src/mood.ts +186 -0
- package/src/music-genre.ts +662 -0
- package/src/music-mood.ts +137 -0
- package/src/object-asset-presets.ts +81 -0
- package/src/parameter-prompt-hint.ts +281 -0
- package/src/person.ts +1368 -0
- package/src/photo-genre.ts +151 -0
- package/src/photographer.ts +612 -0
- package/src/picker-analyzer-registry.ts +374 -0
- package/src/picker-catalogs.ts +858 -0
- package/src/pose.ts +246 -0
- package/src/post-process-effects.ts +94 -0
- package/src/prompt-builder-structured-fields.ts +116 -0
- package/src/prompt-builder.ts +2964 -0
- package/src/prompt-templates.ts +50 -0
- package/src/prompt-wizard-categories.ts +334 -0
- package/src/provider-prompt-doctrine.ts +85 -0
- package/src/render-quality.ts +89 -0
- package/src/resolve-prompt.ts +120 -0
- package/src/seedance-2-inputs.ts +100 -0
- package/src/setting.ts +130 -0
- package/src/sound-aggregator.ts +241 -0
- package/src/style-presets.ts +162 -0
- package/src/style.ts +99 -0
- package/src/styling.ts +585 -0
- package/src/temporal.ts +151 -0
- package/src/transitions.ts +333 -0
- package/src/video-reference-resolver.ts +823 -0
- package/src/voice-character.ts +237 -0
- package/src/voice-delivery.ts +142 -0
- package/src/wardrobe.ts +179 -0
|
@@ -0,0 +1,2964 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image prompt assembly logic shared between frontend and backend.
|
|
3
|
+
* Handles character description expansion, style appending, negative prompt routing,
|
|
4
|
+
* provider-aware prompt truncation (default 5000 chars), and reference image filtering by model support.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { resolveTemplate, applyTemplate } from "./prompt-templates.js"
|
|
8
|
+
import { NATIVE_NEGATIVE_PROMPT_MODELS, MODELS_WITH_REFERENCE_IMAGE_SUPPORT, imageReferenceLimit, getMaxImagePromptChars, getMaxNegativePromptChars } from "@nodaro/shared"
|
|
9
|
+
import { getStylePromptHint } from "./style.js"
|
|
10
|
+
import { findCharacterMentionTokens, type CharacterMentionTokenInfo } from "@nodaro/shared"
|
|
11
|
+
import { usageModeDirective, DEFAULT_USAGE_MODE, type UsageMode } from "@nodaro/shared"
|
|
12
|
+
import { roleToPhrase, defaultRoleForSource, REFERENCE_ROLE_PRESETS, normalizeRoleSlug, resolveDefaultRole } from "@nodaro/shared"
|
|
13
|
+
import { buildIdentityLockLine, withForcedIdentityLock } from "./identity-lock.js"
|
|
14
|
+
import { findLocationMentionTokens, DEFAULT_LOCATION_USAGE_MODE, type LocationMentionTokenInfo, type LocationUsageMode } from "@nodaro/shared"
|
|
15
|
+
import type { CharacterDef, ConnectedReference, IdentityFidelity, IdentityMeta, ReferenceSource, SceneData } from "@nodaro/shared"
|
|
16
|
+
import { locationReferencePhotoKindLabel, type LocationReferencePhotoKind } from "@nodaro/shared"
|
|
17
|
+
|
|
18
|
+
export interface ResolveCharacterMentionsResult {
|
|
19
|
+
/** Prompt with @-tokens replaced by display names + "Use these characters:" directive prepended. */
|
|
20
|
+
prompt: string
|
|
21
|
+
/** Resolved URLs from matched mention tokens, in mention order, deduped via caller responsibility. */
|
|
22
|
+
additionalUrls: string[]
|
|
23
|
+
/**
|
|
24
|
+
* Set of character slugs that had at least one resolved mention token.
|
|
25
|
+
* Callers use this to gate the per-character "no mention → canonical
|
|
26
|
+
* fallback" behavior so a wired character with no `@-mention` still gets
|
|
27
|
+
* its canonical URL attached (existing pre-mention-feature behavior).
|
|
28
|
+
*/
|
|
29
|
+
mentionedCharacterSlugs: Set<string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compose the descriptive body of a character identity bullet:
|
|
34
|
+
* `${subject} — ${canonicalDesc?}. ${elementInjection?}` (parts joined by ". ")
|
|
35
|
+
*
|
|
36
|
+
* Either part may be absent. `canonicalDesc` is mode-gated by the caller (pass
|
|
37
|
+
* `undefined` to omit it for non-identity modes); `elementInjection` is the
|
|
38
|
+
* mode-INDEPENDENT scene-composition fragment wired into the character node's
|
|
39
|
+
* Assets/Prompt handle (held-prop / styling / text). When BOTH are absent the
|
|
40
|
+
* bullet collapses to the bare `subject` — byte-identical to the
|
|
41
|
+
* pre-elementInjection output, which the parity tests pin.
|
|
42
|
+
*/
|
|
43
|
+
function composeIdentityDescPart(
|
|
44
|
+
subject: string,
|
|
45
|
+
canonicalDesc: string | null | undefined,
|
|
46
|
+
elementInjection: string | null | undefined,
|
|
47
|
+
): string {
|
|
48
|
+
const parts: string[] = []
|
|
49
|
+
const c = canonicalDesc?.trim()
|
|
50
|
+
if (c) parts.push(c)
|
|
51
|
+
const e = elementInjection?.trim()
|
|
52
|
+
if (e) parts.push(e)
|
|
53
|
+
return parts.length > 0 ? `${subject} — ${parts.join(". ")}` : subject
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve @-mention tokens in a prompt against connected references.
|
|
58
|
+
* Returns: augmented prompt (with directives prepended + tokens replaced
|
|
59
|
+
* by character display names) and the set of asset URLs to include as refs.
|
|
60
|
+
*
|
|
61
|
+
* Behavior:
|
|
62
|
+
* - Build two lookup Maps: `bySlug` for canonical entries, `byVariant` for variant entries.
|
|
63
|
+
* - Iterate tokens left-to-right so directives are emitted in mention order.
|
|
64
|
+
* - For each token, prefer the variant match when variantSlug present; fall back to canonical.
|
|
65
|
+
* - `charactersSeen` Set guards the long canonical description to appear AT MOST ONCE
|
|
66
|
+
* per character even when the character is mentioned in several tokens.
|
|
67
|
+
* - Build a `replacements` array (token + offset + replacement display name) and
|
|
68
|
+
* apply right-to-left so earlier replacements do not shift later offsets.
|
|
69
|
+
* - Prepend a "Use these characters:\n…" directive section when any directives were emitted.
|
|
70
|
+
* - Each directive bullet leads with the user-visible `Image N (Name)` index pulled
|
|
71
|
+
* directly from the typed token (e.g. `@kira:1:smile` → `Image 1 (Kira)`).
|
|
72
|
+
* This lets the user trace a literal slug in the prompt to its appearance in
|
|
73
|
+
* the final assembled identity-directive block.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveCharacterMentions(
|
|
76
|
+
prompt: string,
|
|
77
|
+
tokens: readonly CharacterMentionTokenInfo[],
|
|
78
|
+
refs: readonly ConnectedReference[],
|
|
79
|
+
): ResolveCharacterMentionsResult {
|
|
80
|
+
const bySlug = new Map<string, ConnectedReference>()
|
|
81
|
+
const byVariant = new Map<string, ConnectedReference>()
|
|
82
|
+
for (const r of refs) {
|
|
83
|
+
if (!r.characterSlug) continue
|
|
84
|
+
if (!r.variantSlug) {
|
|
85
|
+
bySlug.set(r.characterSlug, r)
|
|
86
|
+
} else {
|
|
87
|
+
byVariant.set(`${r.characterSlug}:${r.variantSlug}`, r)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const additionalUrls: string[] = []
|
|
92
|
+
// `firstBulletEmittedFor` tracks the slug of characters that have already
|
|
93
|
+
// produced a non-"none" bullet. Each character emits AT MOST ONE primary
|
|
94
|
+
// bullet (the rich identity / name-only line). "none" mentions don't claim
|
|
95
|
+
// this slot — they emit no bullet AT ALL — so a later "face" mention of the
|
|
96
|
+
// same character still emits its directive bullet on first sight. Without
|
|
97
|
+
// this split, a `[none, face]` mention pair would suppress both bullets
|
|
98
|
+
// because the first iteration would silently consume the "first mention"
|
|
99
|
+
// slot without emitting anything.
|
|
100
|
+
const firstBulletEmittedFor = new Set<string>()
|
|
101
|
+
const mentionedCharacterSlugs = new Set<string>()
|
|
102
|
+
const directiveLines: string[] = []
|
|
103
|
+
|
|
104
|
+
const replacements: Array<{ token: string; offset: number; replacement: string }> = []
|
|
105
|
+
for (const t of tokens) {
|
|
106
|
+
const match = t.variantSlug
|
|
107
|
+
? byVariant.get(`${t.characterSlug}:${t.variantSlug}`)
|
|
108
|
+
: bySlug.get(t.characterSlug)
|
|
109
|
+
if (!match) continue
|
|
110
|
+
|
|
111
|
+
additionalUrls.push(match.url)
|
|
112
|
+
mentionedCharacterSlugs.add(t.characterSlug)
|
|
113
|
+
|
|
114
|
+
// Per-mention effective mode. Resolution order: per-mention slug override
|
|
115
|
+
// → character node default → global DEFAULT_USAGE_MODE. Used both to
|
|
116
|
+
// shape the inline replacement and to decide whether (and how) to emit a
|
|
117
|
+
// bullet for this mention.
|
|
118
|
+
const effectiveMode: UsageMode =
|
|
119
|
+
t.usageMode ?? match.defaultUsageMode ?? DEFAULT_USAGE_MODE
|
|
120
|
+
|
|
121
|
+
const displayName = bySlug.get(t.characterSlug)?.defaultName ?? t.characterSlug
|
|
122
|
+
// Inline replacement of `@kira:1:smile` in the user's prompt. For "none"
|
|
123
|
+
// mode we substitute the bare positional reference (`Image 1`) so the
|
|
124
|
+
// user's sentence reads "show Image 1 dancing" — the image is attached,
|
|
125
|
+
// the model sees the position label, but no character name biases the
|
|
126
|
+
// textual prompt. Every other mode (including "name") keeps the legacy
|
|
127
|
+
// `Kira` substitution so prose flows naturally.
|
|
128
|
+
const replacement = effectiveMode === "none"
|
|
129
|
+
? `Image ${t.imageIndex}`
|
|
130
|
+
: displayName
|
|
131
|
+
replacements.push({ token: t.token, offset: t.offset, replacement })
|
|
132
|
+
|
|
133
|
+
// Bullet emission rules per mode:
|
|
134
|
+
// - "none": NO bullet (zero textual intervention; only the image is
|
|
135
|
+
// attached). Doesn't consume the per-character "first bullet" slot —
|
|
136
|
+
// a later non-none mention can still emit its primary directive.
|
|
137
|
+
// If EVERY mention of a character is "none", that character
|
|
138
|
+
// contributes no bullets at all, so it won't appear under the
|
|
139
|
+
// "Use these characters:" header.
|
|
140
|
+
// - "name": ONE bullet on first non-none mention only —
|
|
141
|
+
// `- Image N (Name)` with no trailing directive. Tells the model who
|
|
142
|
+
// the character is so it can correlate, without prescribing how to
|
|
143
|
+
// use the image.
|
|
144
|
+
// - other modes: ONE bullet on first non-none mention only,
|
|
145
|
+
// `- Image N (Name) — <canonical?>. <directive>`.
|
|
146
|
+
if (effectiveMode === "none") {
|
|
147
|
+
// Suppress everything — the inline replacement above is the only signal.
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
const isFirstBullet = !firstBulletEmittedFor.has(t.characterSlug)
|
|
151
|
+
if (effectiveMode === "name") {
|
|
152
|
+
if (isFirstBullet) {
|
|
153
|
+
directiveLines.push(`- Image ${t.imageIndex} (${displayName})`)
|
|
154
|
+
firstBulletEmittedFor.add(t.characterSlug)
|
|
155
|
+
}
|
|
156
|
+
} else if (isFirstBullet) {
|
|
157
|
+
const directive = usageModeDirective(effectiveMode)
|
|
158
|
+
const subject = `Image ${t.imageIndex} (${displayName})`
|
|
159
|
+
// Canonical description is identity-context — only useful when the model
|
|
160
|
+
// is being asked to lock to that identity ("identical" / "face-pose").
|
|
161
|
+
// For "emotion" / "style" modes the canonical description (face/body/
|
|
162
|
+
// features) is noise relative to the directive's intent, so we omit it
|
|
163
|
+
// and keep the bullet focused on the mode-specific instruction.
|
|
164
|
+
const includeCanonicalDesc =
|
|
165
|
+
effectiveMode === "identical" || effectiveMode === "face-pose"
|
|
166
|
+
// Canonical desc is mode-gated; the character's wired elements
|
|
167
|
+
// (held-prop / styling / text) ride the bullet in every mode that emits
|
|
168
|
+
// one. Byte-identical to the old `${subject} — ${canonical}` form when no
|
|
169
|
+
// injection is present.
|
|
170
|
+
const descPart = composeIdentityDescPart(
|
|
171
|
+
subject,
|
|
172
|
+
includeCanonicalDesc ? match.characterCanonicalDescription : undefined,
|
|
173
|
+
match.elementInjection,
|
|
174
|
+
)
|
|
175
|
+
// `directive` is non-null here because usageModeDirective only returns
|
|
176
|
+
// null for "none"/"name", both of which are already handled above.
|
|
177
|
+
directiveLines.push(`- ${descPart}.${directive ? ` ${directive}` : ""}`)
|
|
178
|
+
firstBulletEmittedFor.add(t.characterSlug)
|
|
179
|
+
}
|
|
180
|
+
// Variant-description sub-line only makes sense alongside an emitted
|
|
181
|
+
// bullet — for "none"/"name" modes it's dropped (those modes are
|
|
182
|
+
// intentionally minimal).
|
|
183
|
+
if (
|
|
184
|
+
t.variantSlug
|
|
185
|
+
&& match.variantDescription
|
|
186
|
+
&& effectiveMode !== "name"
|
|
187
|
+
&& isFirstBullet
|
|
188
|
+
) {
|
|
189
|
+
directiveLines.push(` (in this image: ${match.variantDescription.trim()})`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Apply replacements right-to-left so offsets remain valid.
|
|
194
|
+
let resolvedPrompt = prompt
|
|
195
|
+
for (const r of [...replacements].sort((a, b) => b.offset - a.offset)) {
|
|
196
|
+
resolvedPrompt = resolvedPrompt.slice(0, r.offset)
|
|
197
|
+
+ r.replacement
|
|
198
|
+
+ resolvedPrompt.slice(r.offset + r.token.length)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (directiveLines.length > 0) {
|
|
202
|
+
resolvedPrompt = `Use these characters:\n${directiveLines.join("\n")}\n\n${resolvedPrompt}`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { prompt: resolvedPrompt, additionalUrls, mentionedCharacterSlugs }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface ResolveCharacterMentionsHybridResult {
|
|
209
|
+
/** Body with each `@`-mention replaced INLINE by its role phrase
|
|
210
|
+
* ("the {role} from reference image {LETTER}"). No directive block. */
|
|
211
|
+
prompt: string
|
|
212
|
+
/** Matched character URLs in mention order (deduped by the caller). */
|
|
213
|
+
additionalUrls: string[]
|
|
214
|
+
/** Slugs that had at least one resolved mention. */
|
|
215
|
+
mentionedCharacterSlugs: Set<string>
|
|
216
|
+
/** Per-reference identity-lock lines (non-null, deduped per URL). Caller
|
|
217
|
+
* prepends them as ONE block. */
|
|
218
|
+
lockLines: string[]
|
|
219
|
+
/** Non-empty `elementInjection` fragments (deduped per URL). Caller appends
|
|
220
|
+
* them as trailing scene directives. */
|
|
221
|
+
elementDirectives: string[]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* HYBRID-mode character mention convergence (Unified Reference Roles, Phase A).
|
|
226
|
+
*
|
|
227
|
+
* Where `resolveCharacterMentions` (above) prepends the legacy
|
|
228
|
+
* `"Use these characters:"` bullet block + replaces tokens with bare display
|
|
229
|
+
* names — and is shared VERBATIM with the video resolver, so its contract must
|
|
230
|
+
* NOT change — this renders each `@`-mention as the inline role phrase
|
|
231
|
+
* `"the {role} from reference image {LETTER}"` and surfaces the optional
|
|
232
|
+
* identity-lock + wired `elementInjection` SEPARATELY (the caller prepends one
|
|
233
|
+
* lock block and appends element directives). No directive block is produced.
|
|
234
|
+
*
|
|
235
|
+
* Slot/letter: a character URL's 1-based position in
|
|
236
|
+
* `dedup([...existingUrls, ...mentionUrls])`. The caller appends non-character
|
|
237
|
+
* URLs AFTER this list when it builds `finalIndexByUrl`, so these letters are
|
|
238
|
+
* byte-identical to that canonical map for every character URL.
|
|
239
|
+
*
|
|
240
|
+
* Role: the mention's role/usage-mode 3rd segment — `face`/`pose`/`style` parse
|
|
241
|
+
* as usage modes, `person`/`clothes`/`hair`/`expression` parse as variant slugs
|
|
242
|
+
* but are curated roles. Either way it's reused when present in
|
|
243
|
+
* `REFERENCE_ROLE_PRESETS["wired-character"]`, else falls back to the source
|
|
244
|
+
* default (`"person"`). `identical`/`face-pose`/`emotion`/`name`/`none` (and a
|
|
245
|
+
* real variant slug like `smile`) are not curated roles → `"person"`.
|
|
246
|
+
*
|
|
247
|
+
* Matching: variant-first, canonical-fallback — so a role-word 3rd segment that
|
|
248
|
+
* parsed as a variant slug (e.g. `@kira:1:person`) still attaches the canonical
|
|
249
|
+
* reference instead of being dropped (the legacy resolver skips variant misses).
|
|
250
|
+
*/
|
|
251
|
+
function resolveCharacterMentionsHybrid(
|
|
252
|
+
prompt: string,
|
|
253
|
+
tokens: readonly CharacterMentionTokenInfo[],
|
|
254
|
+
refs: readonly ConnectedReference[],
|
|
255
|
+
existingUrls: readonly string[],
|
|
256
|
+
): ResolveCharacterMentionsHybridResult {
|
|
257
|
+
const bySlug = new Map<string, ConnectedReference>()
|
|
258
|
+
const byVariant = new Map<string, ConnectedReference>()
|
|
259
|
+
for (const r of refs) {
|
|
260
|
+
if (!r.characterSlug) continue
|
|
261
|
+
if (!r.variantSlug) bySlug.set(r.characterSlug, r)
|
|
262
|
+
else byVariant.set(`${r.characterSlug}:${r.variantSlug}`, r)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const presets = REFERENCE_ROLE_PRESETS["wired-character"]
|
|
266
|
+
|
|
267
|
+
const additionalUrls: string[] = []
|
|
268
|
+
const mentionedCharacterSlugs = new Set<string>()
|
|
269
|
+
const refByUrl = new Map<string, ConnectedReference>()
|
|
270
|
+
// Per-mention `~lock` / `~nolock` (Task 4 + F4): the tri-state lock OVERRIDE
|
|
271
|
+
// per attached URL — `true` (force on) / `false` (force off) / absent
|
|
272
|
+
// (inherit the ref default). The lock loop below feeds this to
|
|
273
|
+
// `withForcedIdentityLock` before `buildIdentityLockLine`. Only tokens that
|
|
274
|
+
// carried a sentinel write here (last sentinel wins); a sentinel-less mention
|
|
275
|
+
// never overwrites, so a `~lock` upstream survives a later plain mention.
|
|
276
|
+
const lockOverrideByUrl = new Map<string, boolean>()
|
|
277
|
+
const matched: Array<{ token: string; offset: number; url: string; role: string }> = []
|
|
278
|
+
|
|
279
|
+
for (const t of tokens) {
|
|
280
|
+
// Variant-first, canonical-fallback. `variantMatch` (a REAL matched variant
|
|
281
|
+
// URL) doubles as the signal that the 3rd segment SELECTED a variant rather
|
|
282
|
+
// than acting as a role — see the role derivation below.
|
|
283
|
+
const variantMatch = t.variantSlug
|
|
284
|
+
? byVariant.get(`${t.characterSlug}:${t.variantSlug}`)
|
|
285
|
+
: undefined
|
|
286
|
+
const match = variantMatch ?? bySlug.get(t.characterSlug)
|
|
287
|
+
if (!match || !match.url) continue
|
|
288
|
+
additionalUrls.push(match.url)
|
|
289
|
+
mentionedCharacterSlugs.add(t.characterSlug)
|
|
290
|
+
refByUrl.set(match.url, match)
|
|
291
|
+
if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
|
|
292
|
+
const segment = (t.usageMode ?? t.variantSlug ?? "").trim()
|
|
293
|
+
// Custom roles survive VERBATIM (Unified Reference Roles, Phase D). A
|
|
294
|
+
// non-empty segment is the role when it is a curated preset (face/pose/style
|
|
295
|
+
// modes, person/clothes/… role-slugs) OR a free-form value typed in the
|
|
296
|
+
// variant/role slot that did NOT resolve to a real matched variant URL (e.g.
|
|
297
|
+
// `earrings`) — i.e. it's acting as a role, not selecting a variant. A real
|
|
298
|
+
// variant slug (`smile`) and the directive-only usage modes (identical/
|
|
299
|
+
// face-pose/emotion/name/none) fall back to the NODE default: the character
|
|
300
|
+
// node's `defaultRole` (hybrid dropdown pick, verbatim) → its
|
|
301
|
+
// `defaultUsageMode`-derived role → the source default ("person") — via
|
|
302
|
+
// `resolveDefaultRole` (Character Node Role+Lock). Precedence: per-mention
|
|
303
|
+
// token role → node defaultRole → defaultUsageMode-derived → source default.
|
|
304
|
+
const role =
|
|
305
|
+
segment && (presets.includes(segment) || (t.usageMode == null && !variantMatch))
|
|
306
|
+
? segment
|
|
307
|
+
: resolveDefaultRole(match.defaultRole, match.defaultUsageMode, "wired-character")
|
|
308
|
+
matched.push({ token: t.token, offset: t.offset, url: match.url, role })
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Slot letters from the deduped [existing, mention] URL list — the prefix of
|
|
312
|
+
// the caller's `finalIndexByUrl`, so the letters agree.
|
|
313
|
+
const slotByUrl = new Map<string, number>()
|
|
314
|
+
for (const u of [...existingUrls, ...additionalUrls]) {
|
|
315
|
+
if (!slotByUrl.has(u)) slotByUrl.set(u, slotByUrl.size + 1)
|
|
316
|
+
}
|
|
317
|
+
const bindingFor = (url: string): string => {
|
|
318
|
+
const slot = slotByUrl.get(url)
|
|
319
|
+
return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Replace mention tokens right-to-left so earlier offsets stay valid.
|
|
323
|
+
let resolvedPrompt = prompt
|
|
324
|
+
for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
|
|
325
|
+
const phrase = roleToPhrase(m.role, bindingFor(m.url))
|
|
326
|
+
resolvedPrompt =
|
|
327
|
+
resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// One identity-lock + one element directive per UNIQUE attached URL (a
|
|
331
|
+
// character mentioned N times attaches one URL → one lock / element line).
|
|
332
|
+
const lockLines: string[] = []
|
|
333
|
+
const elementDirectives: string[] = []
|
|
334
|
+
const seenUrls = new Set<string>()
|
|
335
|
+
for (const m of matched) {
|
|
336
|
+
if (seenUrls.has(m.url)) continue
|
|
337
|
+
seenUrls.add(m.url)
|
|
338
|
+
const ref = refByUrl.get(m.url)
|
|
339
|
+
if (!ref) continue
|
|
340
|
+
const binding = bindingFor(m.url)
|
|
341
|
+
const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
|
|
342
|
+
if (lock) lockLines.push(lock)
|
|
343
|
+
const inject = ref.elementInjection?.trim()
|
|
344
|
+
if (inject) elementDirectives.push(inject)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { prompt: resolvedPrompt, additionalUrls, mentionedCharacterSlugs, lockLines, elementDirectives }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export interface ResolveLocationMentionsResult {
|
|
351
|
+
/** Prompt with `@location:N` tokens replaced by display names + a
|
|
352
|
+
* "Use these locations:" directive prepended when at least one bullet
|
|
353
|
+
* fires. */
|
|
354
|
+
prompt: string
|
|
355
|
+
/** Resolved URLs from matched mention tokens, in mention order. Caller
|
|
356
|
+
* is responsible for deduping against the existing URL list (the same
|
|
357
|
+
* contract as `resolveCharacterMentions.additionalUrls`). */
|
|
358
|
+
additionalUrls: string[]
|
|
359
|
+
/** Set of location slugs that had at least one resolved mention. Callers
|
|
360
|
+
* use this to gate "no mention → canonical fallback" behavior (mirrors
|
|
361
|
+
* the character flow — Phase 2 #1 attached the canonical URL via
|
|
362
|
+
* `expandWiredLocationRefs` directly, but a future fallback path may
|
|
363
|
+
* use this set to suppress the canonical URL once the user has explicitly
|
|
364
|
+
* pinned a variant via @-mention). */
|
|
365
|
+
mentionedLocationSlugs: Set<string>
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Per-mode directive text for a location bullet. Mirrors `usageModeDirective`
|
|
370
|
+
* for characters but with the 4-mode location enum:
|
|
371
|
+
* - identical: lock the scene to this exact image (used as background)
|
|
372
|
+
* - style: borrow the look/mood/color palette
|
|
373
|
+
* - layout: borrow the compositional layout / camera framing
|
|
374
|
+
* - none: no bullet emitted (caller handles)
|
|
375
|
+
*/
|
|
376
|
+
function locationModeDirective(mode: LocationUsageMode): string | null {
|
|
377
|
+
switch (mode) {
|
|
378
|
+
case "identical":
|
|
379
|
+
return "use as the background/setting — match the location exactly."
|
|
380
|
+
case "style":
|
|
381
|
+
return "use as a style / mood reference — borrow color, lighting, and atmosphere."
|
|
382
|
+
case "layout":
|
|
383
|
+
return "use as a compositional layout / camera framing reference."
|
|
384
|
+
case "none":
|
|
385
|
+
return null
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Map a location usage-mode to its HYBRID reference role — the vocabulary
|
|
391
|
+
* `roleToPhrase` renders into "the {role} from reference image {LETTER}". The
|
|
392
|
+
* location analog of the character mention's role segment, for the 4-mode
|
|
393
|
+
* location enum:
|
|
394
|
+
* - identical → "background" (lock the scene to this image)
|
|
395
|
+
* - style → "style" (borrow look / mood / palette)
|
|
396
|
+
* - layout → "layout" (borrow compositional framing)
|
|
397
|
+
* - none / undefined / anything else → the source default (`"background"`).
|
|
398
|
+
*
|
|
399
|
+
* Accepts a loose `string` so a `ConnectedReference.defaultUsageMode` (typed as
|
|
400
|
+
* the CHARACTER `UsageMode`, which can't express `"layout"`) flows in without a
|
|
401
|
+
* cast — unknown / non-location modes fall through to the safe source default
|
|
402
|
+
* instead of throwing. The three real roles are members of
|
|
403
|
+
* `REFERENCE_ROLE_PRESETS["wired-location"]`, so the phrasing stays curated.
|
|
404
|
+
*/
|
|
405
|
+
function locationModeToRole(mode: string | null | undefined): string {
|
|
406
|
+
switch (mode) {
|
|
407
|
+
case "identical":
|
|
408
|
+
return "background"
|
|
409
|
+
case "style":
|
|
410
|
+
return "style"
|
|
411
|
+
case "layout":
|
|
412
|
+
return "layout"
|
|
413
|
+
default:
|
|
414
|
+
return defaultRoleForSource("wired-location")
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Resolve `@oldlibrary:1:weather/rain` mentions in the prompt against the
|
|
420
|
+
* pre-expanded `wired-location` ConnectedReferences (from
|
|
421
|
+
* `expandWiredLocationRefs` / `expandLocationNodeIntoRefs`). Mirrors
|
|
422
|
+
* `resolveCharacterMentions` shape:
|
|
423
|
+
*
|
|
424
|
+
* 1. Look up the matching ref by `(locationSlug, bucket, variantSlug)`.
|
|
425
|
+
* 2. Append its URL to `additionalUrls`.
|
|
426
|
+
* 3. Substitute the inline token with the location's display name (or
|
|
427
|
+
* `Image N` for "none" mode — image attached without textual bias).
|
|
428
|
+
* 4. Emit a directive bullet under "Use these locations:" header, with
|
|
429
|
+
* mode-specific verb. "none" mode suppresses the bullet (just like
|
|
430
|
+
* character "none").
|
|
431
|
+
*
|
|
432
|
+
* Per-location single-bullet rule: each location emits AT MOST ONE bullet
|
|
433
|
+
* (the first non-none mention "claims" the slot). Subsequent mentions of the
|
|
434
|
+
* same location still produce inline substitution + URL but no additional
|
|
435
|
+
* bullet, mirroring `resolveCharacterMentions.firstBulletEmittedFor`.
|
|
436
|
+
*
|
|
437
|
+
* Returns `additionalUrls` deduplication is the caller's responsibility,
|
|
438
|
+
* matching the character contract.
|
|
439
|
+
*/
|
|
440
|
+
export function resolveLocationMentions(
|
|
441
|
+
prompt: string,
|
|
442
|
+
tokens: readonly LocationMentionTokenInfo[],
|
|
443
|
+
refs: readonly ConnectedReference[],
|
|
444
|
+
): ResolveLocationMentionsResult {
|
|
445
|
+
// Build lookup maps: canonical (no variant) and per-(bucket/variant).
|
|
446
|
+
const bySlug = new Map<string, ConnectedReference>()
|
|
447
|
+
const byVariant = new Map<string, ConnectedReference>()
|
|
448
|
+
for (const r of refs) {
|
|
449
|
+
if (!r.locationSlug) continue
|
|
450
|
+
if (!r.locationVariantBucket || !r.locationVariantSlug) {
|
|
451
|
+
bySlug.set(r.locationSlug, r)
|
|
452
|
+
} else {
|
|
453
|
+
byVariant.set(
|
|
454
|
+
`${r.locationSlug}:${r.locationVariantBucket}/${r.locationVariantSlug}`,
|
|
455
|
+
r,
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const additionalUrls: string[] = []
|
|
461
|
+
const mentionedLocationSlugs = new Set<string>()
|
|
462
|
+
const firstBulletEmittedFor = new Set<string>()
|
|
463
|
+
const directiveLines: string[] = []
|
|
464
|
+
const replacements: Array<{ token: string; offset: number; replacement: string }> = []
|
|
465
|
+
|
|
466
|
+
for (const t of tokens) {
|
|
467
|
+
// Bare-slug ROLE tokens (Unified Reference Roles, Phase D — e.g.
|
|
468
|
+
// `@old-library:1:background` / `:atmosphere` / `:as-is` /
|
|
469
|
+
// `:empty-background` / `:lighting`) are a HYBRID-only construct. The
|
|
470
|
+
// additive parser now PARSES them (with `t.role` set, no bucket/variant),
|
|
471
|
+
// but in the LEGACY path they must stay literal text exactly as they did
|
|
472
|
+
// pre-Phase-D, when the parser returned null and the token fell through
|
|
473
|
+
// untouched. Skipping here guarantees byte-identical legacy output: NO
|
|
474
|
+
// inline replacement, NO bullet, NO attached URL, and crucially the slug is
|
|
475
|
+
// NOT added to `mentionedLocationSlugs` — so a wired location still
|
|
476
|
+
// auto-attaches via the unchanged non-character canonical path, just as
|
|
477
|
+
// before. Role resolution lives in `resolveLocationMentionsHybrid`, which is
|
|
478
|
+
// untouched. (`layout`/`style` set `usageMode`, not `role`, so they were
|
|
479
|
+
// always modes and are unaffected.)
|
|
480
|
+
if (t.role) continue
|
|
481
|
+
|
|
482
|
+
const match = t.bucket && t.variant
|
|
483
|
+
? byVariant.get(`${t.locationSlug}:${t.bucket}/${t.variant}`)
|
|
484
|
+
: bySlug.get(t.locationSlug)
|
|
485
|
+
if (!match) continue
|
|
486
|
+
|
|
487
|
+
additionalUrls.push(match.url)
|
|
488
|
+
mentionedLocationSlugs.add(t.locationSlug)
|
|
489
|
+
|
|
490
|
+
// Per-mention effective mode. Resolution order: per-mention slug override
|
|
491
|
+
// → location node default (not yet plumbed; reserved for future) → global
|
|
492
|
+
// DEFAULT_LOCATION_USAGE_MODE.
|
|
493
|
+
const effectiveMode: LocationUsageMode =
|
|
494
|
+
t.usageMode ?? DEFAULT_LOCATION_USAGE_MODE
|
|
495
|
+
|
|
496
|
+
// Inline replacement of `@oldlibrary:1:weather/rain` in the user's
|
|
497
|
+
// prompt. For "none" we substitute the bare positional reference so the
|
|
498
|
+
// user's sentence reads "set in Image 1" — the image is attached, the
|
|
499
|
+
// model sees the position label, but no name biases the textual prompt.
|
|
500
|
+
// Every other mode keeps the location display name for natural prose.
|
|
501
|
+
const displayName = bySlug.get(t.locationSlug)?.defaultName ?? t.locationSlug
|
|
502
|
+
const replacement = effectiveMode === "none" ? `Image ${t.imageIndex}` : displayName
|
|
503
|
+
replacements.push({ token: t.token, offset: t.offset, replacement })
|
|
504
|
+
|
|
505
|
+
// Bullet emission. "none" skips entirely; other modes emit one bullet
|
|
506
|
+
// per location on the first non-none mention.
|
|
507
|
+
if (effectiveMode === "none") {
|
|
508
|
+
continue
|
|
509
|
+
}
|
|
510
|
+
const isFirstBullet = !firstBulletEmittedFor.has(t.locationSlug)
|
|
511
|
+
if (!isFirstBullet) continue
|
|
512
|
+
firstBulletEmittedFor.add(t.locationSlug)
|
|
513
|
+
|
|
514
|
+
const directive = locationModeDirective(effectiveMode)
|
|
515
|
+
const subject = `Image ${t.imageIndex} (${displayName})`
|
|
516
|
+
// Canonical-description injection. Mirrors the Phase 2 #1 behavior in
|
|
517
|
+
// `buildIdentityDirective` — only emit for the "identical" mode where
|
|
518
|
+
// the description (env, materials, mood) is on-task; "style" / "layout"
|
|
519
|
+
// modes are about the look/composition and don't need the full
|
|
520
|
+
// description noise.
|
|
521
|
+
const includeCanonicalDesc = effectiveMode === "identical"
|
|
522
|
+
const canonicalDesc = match.locationCanonicalDescription?.trim()
|
|
523
|
+
const descPart = includeCanonicalDesc && canonicalDesc
|
|
524
|
+
? `${subject} — ${canonicalDesc}`
|
|
525
|
+
: subject
|
|
526
|
+
directiveLines.push(`- ${descPart}.${directive ? ` ${directive}` : ""}`)
|
|
527
|
+
|
|
528
|
+
// Variant display-name sub-line: only when the user pinned a specific
|
|
529
|
+
// variant. Distinct from `description` (the user-typed text on the
|
|
530
|
+
// location node) — locationVariantDisplayName is the raw bucket name
|
|
531
|
+
// like "rain" or "neon".
|
|
532
|
+
if (
|
|
533
|
+
t.bucket
|
|
534
|
+
&& t.variant
|
|
535
|
+
&& match.locationVariantDisplayName
|
|
536
|
+
&& match.locationVariantDisplayName !== "canonical"
|
|
537
|
+
) {
|
|
538
|
+
directiveLines.push(` (in this image: ${match.locationVariantDisplayName})`)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Apply replacements right-to-left so offsets stay valid.
|
|
543
|
+
let resolvedPrompt = prompt
|
|
544
|
+
for (const r of [...replacements].sort((a, b) => b.offset - a.offset)) {
|
|
545
|
+
resolvedPrompt = resolvedPrompt.slice(0, r.offset)
|
|
546
|
+
+ r.replacement
|
|
547
|
+
+ resolvedPrompt.slice(r.offset + r.token.length)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (directiveLines.length > 0) {
|
|
551
|
+
resolvedPrompt = `Use these locations:\n${directiveLines.join("\n")}\n\n${resolvedPrompt}`
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return { prompt: resolvedPrompt, additionalUrls, mentionedLocationSlugs }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
interface ResolveLocationMentionsHybridResult {
|
|
558
|
+
/** Body with each `@location` mention replaced INLINE by its role phrase
|
|
559
|
+
* ("the {role} from reference image {LETTER}"). No directive block. */
|
|
560
|
+
prompt: string
|
|
561
|
+
/** Matched location URLs in mention order (deduped by the caller). */
|
|
562
|
+
additionalUrls: string[]
|
|
563
|
+
/** Slugs that had at least one resolved mention. */
|
|
564
|
+
mentionedLocationSlugs: Set<string>
|
|
565
|
+
/** Per-reference opt-in identity-lock lines (deduped per URL). Locks are OFF
|
|
566
|
+
* for locations by default — `buildIdentityLockLine` returns null unless the
|
|
567
|
+
* ref sets `identityLock.enabled === true` with custom text (there is no
|
|
568
|
+
* built-in wired-location lock wording). Caller prepends them as ONE block. */
|
|
569
|
+
lockLines: string[]
|
|
570
|
+
/** Non-empty `elementInjection` fragments (deduped per URL). Caller appends
|
|
571
|
+
* them as trailing scene directives. */
|
|
572
|
+
elementDirectives: string[]
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* HYBRID-mode location mention convergence (Unified Reference Roles, Phase C).
|
|
577
|
+
*
|
|
578
|
+
* The location analog of `resolveCharacterMentionsHybrid`. Where the LEGACY
|
|
579
|
+
* `resolveLocationMentions` (above) prepends a `"Use these locations:"` bullet
|
|
580
|
+
* block + replaces `@location` tokens with display names, this renders each
|
|
581
|
+
* mention as the inline role phrase `"the {role} from reference image {LETTER}"`
|
|
582
|
+
* and surfaces the optional opt-in identity-lock + wired `elementInjection`
|
|
583
|
+
* SEPARATELY (caller prepends one lock block, appends element directives). No
|
|
584
|
+
* directive block is produced.
|
|
585
|
+
*
|
|
586
|
+
* MATCHING is byte-identical to the legacy resolver (variant-first, NO canonical
|
|
587
|
+
* fallback on a variant miss) so the set of attached URLs / matched tokens never
|
|
588
|
+
* diverges between formats — only the rendered phrasing differs.
|
|
589
|
+
*
|
|
590
|
+
* ROLE is `locationModeToRole(mode)` with `mode = perMentionOverride ?? the
|
|
591
|
+
* location node's defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE` — so a node
|
|
592
|
+
* whose default is "style" renders "the style from …" for a bare `@old-library:1`.
|
|
593
|
+
*
|
|
594
|
+
* SLOT/LETTER: a URL's 1-based position in `dedup([...existingUrls,
|
|
595
|
+
* ...mentionUrls])`. The caller passes `existingUrls = [base refs, resolved
|
|
596
|
+
* character mentions]` (location mentions are merged AFTER character mentions and
|
|
597
|
+
* BEFORE the character canonical/extra URLs), so these letters are a prefix of —
|
|
598
|
+
* and byte-identical to — the caller's final `finalIndexByUrl` for every location
|
|
599
|
+
* URL.
|
|
600
|
+
*/
|
|
601
|
+
function resolveLocationMentionsHybrid(
|
|
602
|
+
prompt: string,
|
|
603
|
+
tokens: readonly LocationMentionTokenInfo[],
|
|
604
|
+
refs: readonly ConnectedReference[],
|
|
605
|
+
existingUrls: readonly string[],
|
|
606
|
+
): ResolveLocationMentionsHybridResult {
|
|
607
|
+
const bySlug = new Map<string, ConnectedReference>()
|
|
608
|
+
const byVariant = new Map<string, ConnectedReference>()
|
|
609
|
+
for (const r of refs) {
|
|
610
|
+
if (!r.locationSlug) continue
|
|
611
|
+
if (!r.locationVariantBucket || !r.locationVariantSlug) {
|
|
612
|
+
bySlug.set(r.locationSlug, r)
|
|
613
|
+
} else {
|
|
614
|
+
byVariant.set(`${r.locationSlug}:${r.locationVariantBucket}/${r.locationVariantSlug}`, r)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const additionalUrls: string[] = []
|
|
619
|
+
const mentionedLocationSlugs = new Set<string>()
|
|
620
|
+
const refByUrl = new Map<string, ConnectedReference>()
|
|
621
|
+
// Per-mention `~lock` / `~nolock` (Task 4 + F4): the tri-state lock OVERRIDE
|
|
622
|
+
// per attached URL — `true` (force on, so a location lock line appears even
|
|
623
|
+
// though location locks default OFF) / `false` (force off, suppressing a
|
|
624
|
+
// ref-level enabled lock) / absent (inherit). Fed to `withForcedIdentityLock`
|
|
625
|
+
// below. Only sentinel-bearing mentions write here (last sentinel wins).
|
|
626
|
+
const lockOverrideByUrl = new Map<string, boolean>()
|
|
627
|
+
const matched: Array<{ token: string; offset: number; url: string; role: string }> = []
|
|
628
|
+
|
|
629
|
+
for (const t of tokens) {
|
|
630
|
+
// Variant-first, canonical-fallback-FREE (ternary, NOT `??`) — identical to
|
|
631
|
+
// the legacy resolver so the matched URL set never diverges between formats.
|
|
632
|
+
const match = t.bucket && t.variant
|
|
633
|
+
? byVariant.get(`${t.locationSlug}:${t.bucket}/${t.variant}`)
|
|
634
|
+
: bySlug.get(t.locationSlug)
|
|
635
|
+
if (!match || !match.url) continue
|
|
636
|
+
additionalUrls.push(match.url)
|
|
637
|
+
mentionedLocationSlugs.add(t.locationSlug)
|
|
638
|
+
refByUrl.set(match.url, match)
|
|
639
|
+
if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
|
|
640
|
+
const mode = t.usageMode ?? match.defaultUsageMode ?? DEFAULT_LOCATION_USAGE_MODE
|
|
641
|
+
// A bare-slug ROLE (Unified Reference Roles, Phase D — e.g. `background`,
|
|
642
|
+
// `empty-background`, `as-is`, or a curated custom role) is used VERBATIM:
|
|
643
|
+
// it's acting as a role, not selecting a bucket/variant. `t.role` is the
|
|
644
|
+
// token slug; map it back to the phrase key so `roleToPhrase` hits the
|
|
645
|
+
// non-noun specials (`empty-background` → `empty background`). With no role
|
|
646
|
+
// segment, derive the role from the usage mode (mode-aware default) —
|
|
647
|
+
// byte-identical to the prior behavior for every non-role mention.
|
|
648
|
+
const role = t.role ? normalizeRoleSlug(t.role) : locationModeToRole(mode)
|
|
649
|
+
matched.push({ token: t.token, offset: t.offset, url: match.url, role })
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Slot letters from the deduped [existing, mention] URL list — the prefix of
|
|
653
|
+
// the caller's `finalIndexByUrl`, so the letters agree.
|
|
654
|
+
const slotByUrl = new Map<string, number>()
|
|
655
|
+
for (const u of [...existingUrls, ...additionalUrls]) {
|
|
656
|
+
if (!slotByUrl.has(u)) slotByUrl.set(u, slotByUrl.size + 1)
|
|
657
|
+
}
|
|
658
|
+
const bindingFor = (url: string): string => {
|
|
659
|
+
const slot = slotByUrl.get(url)
|
|
660
|
+
return slot ? `reference image ${slotToLetter(slot)}` : "the reference image"
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Replace mention tokens right-to-left so earlier offsets stay valid.
|
|
664
|
+
let resolvedPrompt = prompt
|
|
665
|
+
for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
|
|
666
|
+
const phrase = roleToPhrase(m.role, bindingFor(m.url))
|
|
667
|
+
resolvedPrompt =
|
|
668
|
+
resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// One opt-in lock + one element directive per UNIQUE attached URL.
|
|
672
|
+
const lockLines: string[] = []
|
|
673
|
+
const elementDirectives: string[] = []
|
|
674
|
+
const seenUrls = new Set<string>()
|
|
675
|
+
for (const m of matched) {
|
|
676
|
+
if (seenUrls.has(m.url)) continue
|
|
677
|
+
seenUrls.add(m.url)
|
|
678
|
+
const ref = refByUrl.get(m.url)
|
|
679
|
+
if (!ref) continue
|
|
680
|
+
const binding = bindingFor(m.url)
|
|
681
|
+
const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
|
|
682
|
+
if (lock) lockLines.push(lock)
|
|
683
|
+
const inject = ref.elementInjection?.trim()
|
|
684
|
+
if (inject) elementDirectives.push(inject)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return { prompt: resolvedPrompt, additionalUrls, mentionedLocationSlugs, lockLines, elementDirectives }
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Build the canonical-fallback directive lines + URLs for wired characters
|
|
692
|
+
* that were NOT @-mentioned in the prompt. Matches the pre-mention behavior:
|
|
693
|
+
* a character wired to a generator without any `@-mention` still contributes
|
|
694
|
+
* its default URL + a strong identity directive, so users who wire a single
|
|
695
|
+
* character without typing anything get the same result they did before the
|
|
696
|
+
* `@-mention` feature shipped.
|
|
697
|
+
*
|
|
698
|
+
* Returns directive lines (matching `resolveCharacterMentions`'s format) and
|
|
699
|
+
* canonical URLs (variantSlug === undefined entries only) keyed by
|
|
700
|
+
* `characterSlug`, deduped. Callers prepend the directives + merge the URLs.
|
|
701
|
+
*
|
|
702
|
+
* Numbering: each emitted line is prefixed with `Image N (Name) — …` where
|
|
703
|
+
* N is the position of this canonical URL in the final `referenceImageUrls`
|
|
704
|
+
* list. The caller passes `startIndex` (1-based) for the first canonical
|
|
705
|
+
* URL; subsequent canonical URLs get `startIndex + 1`, `+ 2`, etc.
|
|
706
|
+
*
|
|
707
|
+
* Without the numeric prefix the model has no way to link "Image 1" in its
|
|
708
|
+
* input array to the directive bullet "shira — young woman…" — which led
|
|
709
|
+
* users to see uncorrelated directives and ungrouped reference images.
|
|
710
|
+
*/
|
|
711
|
+
function buildCanonicalFallback(
|
|
712
|
+
refs: readonly ConnectedReference[],
|
|
713
|
+
mentionedSlugs: ReadonlySet<string>,
|
|
714
|
+
startIndex: number,
|
|
715
|
+
): { directiveLines: string[]; urls: string[] } {
|
|
716
|
+
const directiveLines: string[] = []
|
|
717
|
+
const urls: string[] = []
|
|
718
|
+
const seenSlugs = new Set<string>()
|
|
719
|
+
let cursor = startIndex
|
|
720
|
+
for (const r of refs) {
|
|
721
|
+
if (r.source !== "wired-character") continue
|
|
722
|
+
if (!r.characterSlug) continue
|
|
723
|
+
if (mentionedSlugs.has(r.characterSlug)) continue
|
|
724
|
+
if (seenSlugs.has(r.characterSlug)) continue
|
|
725
|
+
// Canonical entry only — never auto-attach a variant for an unmentioned
|
|
726
|
+
// character. Multiple wired characters each contribute one canonical
|
|
727
|
+
// URL + one directive, mirroring the legacy auto-attach behavior.
|
|
728
|
+
if (r.variantSlug) continue
|
|
729
|
+
if (!r.url) continue
|
|
730
|
+
seenSlugs.add(r.characterSlug)
|
|
731
|
+
urls.push(r.url)
|
|
732
|
+
const displayName = r.defaultName || r.characterSlug
|
|
733
|
+
// Mode source: character node's `defaultUsageMode` (if set), else the
|
|
734
|
+
// global `DEFAULT_USAGE_MODE` ("identical"). Without a slug-level
|
|
735
|
+
// override available here (the user hasn't @-mentioned this character),
|
|
736
|
+
// the node's default is the only signal — preserves the legacy "match
|
|
737
|
+
// exactly" behavior when no default is configured.
|
|
738
|
+
const effectiveMode: UsageMode = r.defaultUsageMode ?? DEFAULT_USAGE_MODE
|
|
739
|
+
// Minimal-intervention modes:
|
|
740
|
+
// - "none": URL is attached but NO bullet is emitted — the visual
|
|
741
|
+
// speaks for itself, no textual bias.
|
|
742
|
+
// - "name": one bullet with the name, no trailing directive — lets the
|
|
743
|
+
// model correlate the position with a named entity without
|
|
744
|
+
// prescribing usage.
|
|
745
|
+
if (effectiveMode === "none") {
|
|
746
|
+
// Bare URL attachment, no bullet — `cursor` still advances so any
|
|
747
|
+
// downstream extras that pair-back via "same subject as Image N" see
|
|
748
|
+
// the correct positional slot for this character.
|
|
749
|
+
cursor += 1
|
|
750
|
+
continue
|
|
751
|
+
}
|
|
752
|
+
if (effectiveMode === "name") {
|
|
753
|
+
directiveLines.push(`- Image ${cursor} (${displayName})`)
|
|
754
|
+
cursor += 1
|
|
755
|
+
continue
|
|
756
|
+
}
|
|
757
|
+
const directive = usageModeDirective(effectiveMode)
|
|
758
|
+
const includeCanonicalDesc =
|
|
759
|
+
effectiveMode === "identical" || effectiveMode === "face-pose"
|
|
760
|
+
// Subject line includes the numeric position so the model can correlate
|
|
761
|
+
// "Image N in the input array" with the named character in the directive.
|
|
762
|
+
const subject = `Image ${cursor} (${displayName})`
|
|
763
|
+
// Wired elements (held-prop / styling / text) ride this bullet alongside
|
|
764
|
+
// the (mode-gated) canonical description. This is the reported path — a
|
|
765
|
+
// character wired with no @-mention — so a composed character surfaces its
|
|
766
|
+
// elements wherever it's used downstream.
|
|
767
|
+
const descPart = composeIdentityDescPart(
|
|
768
|
+
subject,
|
|
769
|
+
includeCanonicalDesc ? r.characterCanonicalDescription : undefined,
|
|
770
|
+
r.elementInjection,
|
|
771
|
+
)
|
|
772
|
+
// `directive` is non-null here ("none"/"name" already short-circuited).
|
|
773
|
+
directiveLines.push(`- ${descPart}.${directive ? ` ${directive}` : ""}`)
|
|
774
|
+
cursor += 1
|
|
775
|
+
}
|
|
776
|
+
return { directiveLines, urls }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Build directive lines + URLs for user-attached "extra reference images"
|
|
781
|
+
* (entries with `isExtraRef === true`). Runs alongside `buildCanonicalFallback`
|
|
782
|
+
* — both feed into the same "Use these characters:\n..." block prepended to
|
|
783
|
+
* the prompt.
|
|
784
|
+
*
|
|
785
|
+
* Numbering rule: starts at `nextImageIndex` (passed in by the caller — equal
|
|
786
|
+
* to the count of already-emitted canonical fallback URLs + the count of
|
|
787
|
+
* mention URLs) and increments by 1 per extra ref. The caller's URL merge
|
|
788
|
+
* order must match this counter so "Image N" in the directive resolves to
|
|
789
|
+
* the N-th URL in the final `referenceImageUrls` array.
|
|
790
|
+
*
|
|
791
|
+
* Per-extra directive shape:
|
|
792
|
+
* - manual extra (source === "manual" + description set)
|
|
793
|
+
* → `Image N (reference): <description>.`
|
|
794
|
+
* - character extra (source === "wired-character" + characterSlug set
|
|
795
|
+
* + description set) where the SAME `characterSlug` was already emitted
|
|
796
|
+
* as a mention or canonical entry
|
|
797
|
+
* → `Image N is the same subject as Image M, <description>.`
|
|
798
|
+
* where M is the position of the earlier same-character image.
|
|
799
|
+
* - character extra of a previously-unseen character (no canonical, no
|
|
800
|
+
* mention) → emit a canonical-style directive (display name + mode
|
|
801
|
+
* directive). Acts as the "first sight" attachment for that character.
|
|
802
|
+
*
|
|
803
|
+
* The caller is responsible for:
|
|
804
|
+
* 1. Merging `urls` into `referenceImageUrls` in the same order as the
|
|
805
|
+
* directive lines (so positions align).
|
|
806
|
+
* 2. Concatenating `directiveLines` onto the existing "Use these characters:"
|
|
807
|
+
* block (or creating a new one).
|
|
808
|
+
*
|
|
809
|
+
* `emittedCharacterPositions` is a map from characterSlug → position (1-based)
|
|
810
|
+
* of the FIRST emitted image for that character. The caller pre-populates it
|
|
811
|
+
* with positions from mentions + canonical fallback so that "Image B is the
|
|
812
|
+
* same subject as Image A" can reference an earlier slot correctly.
|
|
813
|
+
*/
|
|
814
|
+
function buildExtraRefDirectives(
|
|
815
|
+
refs: readonly ConnectedReference[],
|
|
816
|
+
emittedCharacterPositions: ReadonlyMap<string, number>,
|
|
817
|
+
startIndex: number,
|
|
818
|
+
): { directiveLines: string[]; urls: string[] } {
|
|
819
|
+
const directiveLines: string[] = []
|
|
820
|
+
const urls: string[] = []
|
|
821
|
+
// Local copy of the position map so first-sight extras can be referenced by
|
|
822
|
+
// later extras of the same character (e.g. two extras of Kira where the
|
|
823
|
+
// first becomes "Image M" and the second pairs back to "M").
|
|
824
|
+
const positionsByChar = new Map(emittedCharacterPositions)
|
|
825
|
+
let cursor = startIndex
|
|
826
|
+
for (const r of refs) {
|
|
827
|
+
if (!r.isExtraRef) continue
|
|
828
|
+
if (!r.url) continue
|
|
829
|
+
const description = (r.description ?? r.variantDescription ?? "").trim()
|
|
830
|
+
// Character extra
|
|
831
|
+
if (r.source === "wired-character" && r.characterSlug) {
|
|
832
|
+
const effectiveMode: UsageMode = r.defaultUsageMode ?? DEFAULT_USAGE_MODE
|
|
833
|
+
const earlierPos = positionsByChar.get(r.characterSlug)
|
|
834
|
+
if (earlierPos !== undefined) {
|
|
835
|
+
// Pair-back form. `Image N is the same subject as Image M, <desc>.`
|
|
836
|
+
// Description is optional — when absent we still emit the pairing.
|
|
837
|
+
// Minimal-intervention modes suppress even the pair-back bullet so
|
|
838
|
+
// the user's "don't bias with text" intent extends to extras.
|
|
839
|
+
if (effectiveMode !== "none") {
|
|
840
|
+
const tail = description ? `, ${description}` : ""
|
|
841
|
+
directiveLines.push(`- Image ${cursor} is the same subject as Image ${earlierPos}${tail}.`)
|
|
842
|
+
}
|
|
843
|
+
} else if (effectiveMode === "none") {
|
|
844
|
+
// First sight of this character via an extra ref, but mode is "none".
|
|
845
|
+
// Attach the URL, emit no bullet, record the position so any later
|
|
846
|
+
// extras of the same character that pair-back land on the right slot.
|
|
847
|
+
positionsByChar.set(r.characterSlug, cursor)
|
|
848
|
+
} else if (effectiveMode === "name") {
|
|
849
|
+
// "Name only" — labeled subject + the per-ref description (when
|
|
850
|
+
// present), no trailing directive.
|
|
851
|
+
const displayName = r.defaultName || r.characterSlug
|
|
852
|
+
const subject = `Image ${cursor} (${displayName})`
|
|
853
|
+
const descPart = description ? `${subject} — ${description}` : subject
|
|
854
|
+
directiveLines.push(`- ${descPart}.`)
|
|
855
|
+
positionsByChar.set(r.characterSlug, cursor)
|
|
856
|
+
} else {
|
|
857
|
+
// First sight of this character via an extra ref. Emit a canonical-style
|
|
858
|
+
// directive — the description (or the character's canonical desc) is
|
|
859
|
+
// the descriptive part, and the usage mode is whatever the caller
|
|
860
|
+
// resolved onto `defaultUsageMode` (per-ref override → node default →
|
|
861
|
+
// identical, applied when the ExtraRef was mapped to a ConnectedReference).
|
|
862
|
+
const directive = usageModeDirective(effectiveMode)
|
|
863
|
+
const displayName = r.defaultName || r.characterSlug
|
|
864
|
+
const subject = `Image ${cursor} (${displayName})`
|
|
865
|
+
const includeCanonicalDesc =
|
|
866
|
+
effectiveMode === "identical" || effectiveMode === "face-pose"
|
|
867
|
+
const canonicalDesc = r.characterCanonicalDescription?.trim()
|
|
868
|
+
// Description preference: per-ref description > canonical (when mode
|
|
869
|
+
// allows it) > nothing. The per-ref description is what the user
|
|
870
|
+
// typed in the extra-ref row, so it always wins. The character's wired
|
|
871
|
+
// elements (held-prop / styling) ride alongside whichever wins.
|
|
872
|
+
const chosenDesc = description
|
|
873
|
+
? description
|
|
874
|
+
: (includeCanonicalDesc ? canonicalDesc : undefined)
|
|
875
|
+
const descPart = composeIdentityDescPart(subject, chosenDesc, r.elementInjection)
|
|
876
|
+
directiveLines.push(`- ${descPart}.${directive ? ` ${directive}` : ""}`)
|
|
877
|
+
positionsByChar.set(r.characterSlug, cursor)
|
|
878
|
+
}
|
|
879
|
+
} else {
|
|
880
|
+
// Manual / generic extra. Description (when present) goes in the
|
|
881
|
+
// parenthetical; absent description still emits a positional marker so
|
|
882
|
+
// the URL position is unambiguous in the prompt.
|
|
883
|
+
if (description) {
|
|
884
|
+
directiveLines.push(`- Image ${cursor} (reference): ${description}.`)
|
|
885
|
+
} else {
|
|
886
|
+
directiveLines.push(`- Image ${cursor} (reference).`)
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
urls.push(r.url)
|
|
890
|
+
cursor += 1
|
|
891
|
+
}
|
|
892
|
+
return { directiveLines, urls }
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
// HYBRID variants of the canonical-fallback + extra-ref directives (Unified
|
|
897
|
+
// Reference Roles, Phase A — Task 4). Where the LEGACY builders above prepend a
|
|
898
|
+
// "Use these characters:" bullet block with numeric "Image N" addressing, these
|
|
899
|
+
// attach the SAME reference URLs but render each entry as an inline, lowercase
|
|
900
|
+
// role phrase / pair-back directive bound to a lettered slot ("reference image
|
|
901
|
+
// A"). Letters come from the caller's unified slot map (the same
|
|
902
|
+
// `finalIndexByUrl` order as the rest of the hybrid path), so the numbering
|
|
903
|
+
// agrees byte-for-byte. The legacy builders stay untouched.
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Select the unmentioned wired-character refs that contribute a canonical
|
|
908
|
+
* fallback URL in hybrid mode. SELECTION ONLY — identical filtering to
|
|
909
|
+
* `buildCanonicalFallback` (canonical entry only, deduped per `characterSlug`,
|
|
910
|
+
* skipping @-mentioned slugs) so the two paths never disagree on WHICH
|
|
911
|
+
* characters auto-attach; rendering happens in `renderCanonicalFallbackHybrid`
|
|
912
|
+
* once the slot letters are known. Keep this filter in sync with
|
|
913
|
+
* `buildCanonicalFallback`.
|
|
914
|
+
*/
|
|
915
|
+
function selectCanonicalFallbackRefs(
|
|
916
|
+
refs: readonly ConnectedReference[],
|
|
917
|
+
mentionedSlugs: ReadonlySet<string>,
|
|
918
|
+
): ConnectedReference[] {
|
|
919
|
+
const out: ConnectedReference[] = []
|
|
920
|
+
const seenSlugs = new Set<string>()
|
|
921
|
+
for (const r of refs) {
|
|
922
|
+
if (r.source !== "wired-character") continue
|
|
923
|
+
if (!r.characterSlug) continue
|
|
924
|
+
if (mentionedSlugs.has(r.characterSlug)) continue
|
|
925
|
+
if (seenSlugs.has(r.characterSlug)) continue
|
|
926
|
+
if (r.variantSlug) continue // never auto-attach a variant for an unmentioned char
|
|
927
|
+
if (!r.url) continue
|
|
928
|
+
seenSlugs.add(r.characterSlug)
|
|
929
|
+
out.push(r)
|
|
930
|
+
}
|
|
931
|
+
return out
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Render the hybrid canonical fallback: each unmentioned wired character →
|
|
936
|
+
* the inline role phrase `roleToPhrase(defaultRoleForSource(source), binding)`
|
|
937
|
+
* (the caller appends these to the body as trailing scene directives) + its
|
|
938
|
+
* opt-in identity-lock line (null by default — emitted only when the ref sets
|
|
939
|
+
* `identityLock.enabled === true`) + its wired `elementInjection` (held-prop /
|
|
940
|
+
* styling / text) as a trailing scene directive. `letterForUrl` maps the ref's
|
|
941
|
+
* already-attached URL to its slot letter.
|
|
942
|
+
*
|
|
943
|
+
* `elementInjection` must NOT be silently dropped for an unmentioned character:
|
|
944
|
+
* the legacy `buildCanonicalFallback` folds it into the bullet via
|
|
945
|
+
* `composeIdentityDescPart`, and the hybrid MENTION path surfaces it via
|
|
946
|
+
* `resolveCharacterMentionsHybrid`'s `elementDirectives`. We mirror the latter's
|
|
947
|
+
* shape exactly (raw trimmed injection string, surfaced as its own trailing
|
|
948
|
+
* line) so mentioned and unmentioned characters stay consistent in hybrid mode.
|
|
949
|
+
*/
|
|
950
|
+
function renderCanonicalFallbackHybrid(
|
|
951
|
+
refs: readonly ConnectedReference[],
|
|
952
|
+
letterForUrl: (url: string) => string,
|
|
953
|
+
): { phrases: string[]; lockLines: string[]; elementDirectives: string[] } {
|
|
954
|
+
const phrases: string[] = []
|
|
955
|
+
const lockLines: string[] = []
|
|
956
|
+
const elementDirectives: string[] = []
|
|
957
|
+
for (const r of refs) {
|
|
958
|
+
const binding = `reference image ${letterForUrl(r.url)}`
|
|
959
|
+
// Node-default role chain (Character Node Role+Lock): the character node's
|
|
960
|
+
// `defaultRole` (hybrid dropdown pick, verbatim — Custom survives) → its
|
|
961
|
+
// `defaultUsageMode`-derived role → the source default ("person"). This is
|
|
962
|
+
// the MOST COMMON wiring (wired, not @-mentioned), which previously
|
|
963
|
+
// hardcoded the source default and ignored the node entirely.
|
|
964
|
+
phrases.push(roleToPhrase(resolveDefaultRole(r.defaultRole, r.defaultUsageMode, r.source), binding))
|
|
965
|
+
const lock = buildIdentityLockLine(r, binding)
|
|
966
|
+
if (lock) lockLines.push(lock)
|
|
967
|
+
const inject = r.elementInjection?.trim()
|
|
968
|
+
if (inject) elementDirectives.push(inject)
|
|
969
|
+
}
|
|
970
|
+
return { phrases, lockLines, elementDirectives }
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Render the hybrid extra-ref directives. Mirrors `buildExtraRefDirectives`'s
|
|
975
|
+
* manual-vs-pair-back SHAPES, but with lettered bindings + lowercase phrasing
|
|
976
|
+
* instead of "Image N":
|
|
977
|
+
* - manual extra (with description) → "<description> (reference image L)."
|
|
978
|
+
* - character extra, earlier sibling → "reference image L is the same subject
|
|
979
|
+
* as reference image M[, <desc>]."
|
|
980
|
+
* - character extra, first sight → role phrase [+ ", <desc>"] + opt-in lock
|
|
981
|
+
* + wired `elementInjection` (surfaced as
|
|
982
|
+
* a SEPARATE trailing directive, mirroring
|
|
983
|
+
* `renderCanonicalFallbackHybrid`).
|
|
984
|
+
* `seedLetterByChar` carries each character's FIRST emitted letter from the
|
|
985
|
+
* mention / canonical-fallback pass, so a picked-variant extra pairs back to the
|
|
986
|
+
* earlier reference; first-sight extras record their own letter for any later
|
|
987
|
+
* same-character extras. A description-less manual extra attaches its URL with
|
|
988
|
+
* no directive (nothing to surface).
|
|
989
|
+
*
|
|
990
|
+
* `elementInjection` must NOT be silently dropped for a first-sight extra-ref
|
|
991
|
+
* character: the legacy `buildExtraRefDirectives` folds it into the first-sight
|
|
992
|
+
* bullet via `composeIdentityDescPart`, so the hybrid path surfaces the trimmed
|
|
993
|
+
* injection as its own trailing line (same shape as the canonical / mention
|
|
994
|
+
* paths). Only the FIRST-SIGHT branch attaches it — a pair-back extra references
|
|
995
|
+
* an earlier image whose element directive was already emitted there.
|
|
996
|
+
*/
|
|
997
|
+
function renderExtraRefsHybrid(
|
|
998
|
+
extraRefs: readonly ConnectedReference[],
|
|
999
|
+
letterForUrl: (url: string) => string,
|
|
1000
|
+
seedLetterByChar: ReadonlyMap<string, string>,
|
|
1001
|
+
): { bodyLines: string[]; lockLines: string[]; elementDirectives: string[] } {
|
|
1002
|
+
const bodyLines: string[] = []
|
|
1003
|
+
const lockLines: string[] = []
|
|
1004
|
+
const elementDirectives: string[] = []
|
|
1005
|
+
const firstLetterByChar = new Map(seedLetterByChar)
|
|
1006
|
+
for (const r of extraRefs) {
|
|
1007
|
+
if (!r.url) continue
|
|
1008
|
+
const letter = letterForUrl(r.url)
|
|
1009
|
+
const binding = `reference image ${letter}`
|
|
1010
|
+
const description = (r.description ?? r.variantDescription ?? "").trim()
|
|
1011
|
+
if (r.source === "wired-character" && r.characterSlug) {
|
|
1012
|
+
const earlier = firstLetterByChar.get(r.characterSlug)
|
|
1013
|
+
if (earlier !== undefined && earlier !== letter) {
|
|
1014
|
+
const tail = description ? `, ${description}` : ""
|
|
1015
|
+
bodyLines.push(`${binding} is the same subject as reference image ${earlier}${tail}.`)
|
|
1016
|
+
} else {
|
|
1017
|
+
// Role chain (Character Node Role+Lock): the node's `defaultRole`
|
|
1018
|
+
// (hybrid dropdown pick, VERBATIM — Custom survives; the expander only
|
|
1019
|
+
// stamps it when the extra carries no per-ref usageMode override) → the
|
|
1020
|
+
// COALESCED `defaultUsageMode`-derived role → the source default — via
|
|
1021
|
+
// the shared `resolveDefaultRole` helper.
|
|
1022
|
+
//
|
|
1023
|
+
// `defaultUsageMode` here is COALESCED (`expandExtraRefsToConnected-
|
|
1024
|
+
// References` folds per-ref `usageMode` → char-node default →
|
|
1025
|
+
// "identical"), so a character extra always carries a defined mode.
|
|
1026
|
+
// The VIDEO extras path (`video-reference-resolver.ts`) feeds the same
|
|
1027
|
+
// helper its own coalesced `effectiveMode` + `meta.defaultRole` — image
|
|
1028
|
+
// and video are fully converged (same helper, same precedence); pinned
|
|
1029
|
+
// by `character-convergence-image.test.ts`.
|
|
1030
|
+
const role = resolveDefaultRole(r.defaultRole, r.defaultUsageMode, r.source)
|
|
1031
|
+
const phrase = roleToPhrase(role, binding)
|
|
1032
|
+
bodyLines.push(description ? `${phrase}, ${description}.` : `${phrase}.`)
|
|
1033
|
+
const lock = buildIdentityLockLine(r, binding)
|
|
1034
|
+
if (lock) lockLines.push(lock)
|
|
1035
|
+
const inject = r.elementInjection?.trim()
|
|
1036
|
+
if (inject) elementDirectives.push(inject)
|
|
1037
|
+
firstLetterByChar.set(r.characterSlug, letter)
|
|
1038
|
+
}
|
|
1039
|
+
} else if (description) {
|
|
1040
|
+
bodyLines.push(`${description} (${binding}).`)
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return { bodyLines, lockLines, elementDirectives }
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Render the hybrid canonical convergence for UNMENTIONED wired locations (the
|
|
1048
|
+
* location analog of `renderCanonicalFallbackHybrid`). Each unmentioned
|
|
1049
|
+
* `wired-location` ref → the inline role phrase
|
|
1050
|
+
* `roleToPhrase(locationModeToRole(defaultUsageMode), binding)` (the caller
|
|
1051
|
+
* appends these as trailing scene directives) + its opt-in identity-lock line
|
|
1052
|
+
* (null unless the ref enables one — locations have no built-in lock wording) +
|
|
1053
|
+
* its wired `elementInjection` as a trailing directive.
|
|
1054
|
+
*
|
|
1055
|
+
* Unlike characters — whose canonical URLs are merged in Phase 0 — unmentioned
|
|
1056
|
+
* locations flow through the New path's `nonCharacterRefs` / `finalIndexByUrl`,
|
|
1057
|
+
* so the slot letter is read straight from `finalIndexByUrl` (the single source
|
|
1058
|
+
* of truth for the assembled URL order). MENTIONED locations were already
|
|
1059
|
+
* converged inline in Phase 0 and filtered out of `nonCharacterRefs`, so they
|
|
1060
|
+
* never reach here. Deduped per URL to mirror `buildNonCharacterDirectives`'s
|
|
1061
|
+
* per-URL `coveredUrls` guard (a location wired twice → one phrase).
|
|
1062
|
+
*
|
|
1063
|
+
* `coveredUrls` is the set of URLs ALREADY expanded inline by an `{image:N}`
|
|
1064
|
+
* token in this hybrid scene (derived exactly as the legacy builder does — see
|
|
1065
|
+
* the call site). A location that is BOTH unmentioned AND `{image:N}`-token-
|
|
1066
|
+
* referenced is rendered ONCE (inline, via the scene); we `continue` here so it
|
|
1067
|
+
* is not ALSO emitted as a trailing canonical phrase (the C1 review Minor).
|
|
1068
|
+
*/
|
|
1069
|
+
function renderLocationCanonicalHybrid(
|
|
1070
|
+
nonCharacterRefs: readonly ConnectedReference[],
|
|
1071
|
+
finalIndexByUrl: ReadonlyMap<string, number>,
|
|
1072
|
+
coveredUrls: ReadonlySet<string>,
|
|
1073
|
+
): { phrases: string[]; lockLines: string[]; elementDirectives: string[] } {
|
|
1074
|
+
const phrases: string[] = []
|
|
1075
|
+
const lockLines: string[] = []
|
|
1076
|
+
const elementDirectives: string[] = []
|
|
1077
|
+
const seenUrls = new Set<string>()
|
|
1078
|
+
for (const r of nonCharacterRefs) {
|
|
1079
|
+
if (r.source !== "wired-location") continue
|
|
1080
|
+
if (!r.url || seenUrls.has(r.url) || coveredUrls.has(r.url)) continue
|
|
1081
|
+
const slot = finalIndexByUrl.get(r.url)
|
|
1082
|
+
if (!slot) continue
|
|
1083
|
+
seenUrls.add(r.url)
|
|
1084
|
+
const binding = `reference image ${slotToLetter(slot)}`
|
|
1085
|
+
phrases.push(roleToPhrase(locationModeToRole(r.defaultUsageMode), binding))
|
|
1086
|
+
const lock = buildIdentityLockLine(r, binding)
|
|
1087
|
+
if (lock) lockLines.push(lock)
|
|
1088
|
+
const inject = r.elementInjection?.trim()
|
|
1089
|
+
if (inject) elementDirectives.push(inject)
|
|
1090
|
+
}
|
|
1091
|
+
return { phrases, lockLines, elementDirectives }
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Render the hybrid canonical convergence for UNMENTIONED wired objects /
|
|
1096
|
+
* creatures (the object/creature analog of `renderLocationCanonicalHybrid`).
|
|
1097
|
+
* Each unmentioned `wired-object` ref → "the object from reference image
|
|
1098
|
+
* {LETTER}"; each `wired-creature` → "the creature from reference image
|
|
1099
|
+
* {LETTER}" — via `roleToPhrase(defaultRoleForSource(source), binding)` (the
|
|
1100
|
+
* source-default role, mirroring `renderCanonicalFallbackHybrid` for characters,
|
|
1101
|
+
* not the location's mode-aware role) + its opt-in identity-lock line (null by
|
|
1102
|
+
* default — `wired-object` has no built-in lock wording at all; `wired-creature`
|
|
1103
|
+
* has wording but it is OFF unless `identityLock.enabled === true`, per Plan A's
|
|
1104
|
+
* default-off flip) + its wired `elementInjection` as a trailing directive.
|
|
1105
|
+
*
|
|
1106
|
+
* Objects/creatures have NO `@-mention` path, so the ONLY way one renders inline
|
|
1107
|
+
* is an `{image:N}` token. `coveredUrls` (the URLs already expanded by such a
|
|
1108
|
+
* token in this scene) is threaded in so a wired object/creature that is BOTH
|
|
1109
|
+
* unmentioned AND `{image:N}`-referenced renders ONCE (inline), never also as a
|
|
1110
|
+
* trailing canonical phrase. Deduped per URL (an object wired twice → one phrase).
|
|
1111
|
+
*/
|
|
1112
|
+
function renderObjectCreatureCanonicalHybrid(
|
|
1113
|
+
nonCharacterRefs: readonly ConnectedReference[],
|
|
1114
|
+
finalIndexByUrl: ReadonlyMap<string, number>,
|
|
1115
|
+
coveredUrls: ReadonlySet<string>,
|
|
1116
|
+
): { phrases: string[]; lockLines: string[]; elementDirectives: string[] } {
|
|
1117
|
+
const phrases: string[] = []
|
|
1118
|
+
const lockLines: string[] = []
|
|
1119
|
+
const elementDirectives: string[] = []
|
|
1120
|
+
const seenUrls = new Set<string>()
|
|
1121
|
+
for (const r of nonCharacterRefs) {
|
|
1122
|
+
if (r.source !== "wired-object" && r.source !== "wired-creature") continue
|
|
1123
|
+
if (!r.url || seenUrls.has(r.url) || coveredUrls.has(r.url)) continue
|
|
1124
|
+
const slot = finalIndexByUrl.get(r.url)
|
|
1125
|
+
if (!slot) continue
|
|
1126
|
+
seenUrls.add(r.url)
|
|
1127
|
+
const binding = `reference image ${slotToLetter(slot)}`
|
|
1128
|
+
phrases.push(roleToPhrase(defaultRoleForSource(r.source), binding))
|
|
1129
|
+
const lock = buildIdentityLockLine(r, binding)
|
|
1130
|
+
if (lock) lockLines.push(lock)
|
|
1131
|
+
const inject = r.elementInjection?.trim()
|
|
1132
|
+
if (inject) elementDirectives.push(inject)
|
|
1133
|
+
}
|
|
1134
|
+
return { phrases, lockLines, elementDirectives }
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Per-character extra refs need to know which numbered slot the EARLIER
|
|
1139
|
+
* emitted image of the same character lives in. This helper walks the
|
|
1140
|
+
* URL-merge order built up by `buildImagePrompt` and returns the
|
|
1141
|
+
* `characterSlug → first-position` map used by `buildExtraRefDirectives`.
|
|
1142
|
+
*
|
|
1143
|
+
* The merge order matches `mergedUrls` in `buildImagePrompt`:
|
|
1144
|
+
* [pre-existing referenceImageUrls] + [resolved mentions] + [canonical fallback]
|
|
1145
|
+
* (followed by extras when this helper is consulted).
|
|
1146
|
+
*
|
|
1147
|
+
* For each URL in the merged list, we look up the originating
|
|
1148
|
+
* `ConnectedReference` to learn its `characterSlug`. The first time we see
|
|
1149
|
+
* a slug, we record its position; later same-slug images don't overwrite.
|
|
1150
|
+
*/
|
|
1151
|
+
/**
|
|
1152
|
+
* Build a stable-tile-ID-per-URL mapping for the FINAL URL list emitted by
|
|
1153
|
+
* `buildImagePrompt`. Mirrors the scheme in `compute-injected-refs.ts` so the
|
|
1154
|
+
* frontend reorder UI and the backend `referenceOrder` sort agree.
|
|
1155
|
+
*
|
|
1156
|
+
* Used internally by `applyReferenceOrder` below. Kept as a separate helper
|
|
1157
|
+
* so the test in `prompt-builder.test.ts` can pin the ID scheme contract.
|
|
1158
|
+
*
|
|
1159
|
+
* Resolution order (first wins per URL):
|
|
1160
|
+
* 1. URL is a wired character variant whose @-mention is in `prompt` →
|
|
1161
|
+
* `mention:<slug>:<variant|canonical>`. Variant slug derived from the
|
|
1162
|
+
* mention token, NOT the ref's `variantSlug` — the same URL can match
|
|
1163
|
+
* multiple variants in edge cases, but the mention token is the
|
|
1164
|
+
* authoritative user-typed source.
|
|
1165
|
+
* 2. URL belongs to an `isExtraRef` entry → `wired:<id>` (the extra ref's
|
|
1166
|
+
* stable id, which the consumer panel keeps in sync with the row's id).
|
|
1167
|
+
* 3. URL is a non-character wired ref → `wired:<sourceNodeId>` via the
|
|
1168
|
+
* provided `sourceNodeIdById` map, falling back to `ref.id`.
|
|
1169
|
+
* 4. URL is a wired character canonical (no mention) → `char-canonical:<slug>`.
|
|
1170
|
+
*
|
|
1171
|
+
* URLs not matched by any of the above keep a null entry; they're sorted
|
|
1172
|
+
* stable after the matched URLs and don't participate in reorder.
|
|
1173
|
+
*/
|
|
1174
|
+
function buildTileIdForUrl(
|
|
1175
|
+
urls: readonly string[],
|
|
1176
|
+
refs: readonly ConnectedReference[],
|
|
1177
|
+
prompt: string,
|
|
1178
|
+
sourceNodeIdById?: ReadonlyMap<string, string>,
|
|
1179
|
+
): Array<string | null> {
|
|
1180
|
+
// Build slug → mention's variant decision (the first time the mention
|
|
1181
|
+
// resolved per slug+variant): used to key mention URLs.
|
|
1182
|
+
const knownSlugs = Array.from(
|
|
1183
|
+
new Set(
|
|
1184
|
+
refs.map((r) => r.characterSlug).filter((s): s is string => Boolean(s)),
|
|
1185
|
+
),
|
|
1186
|
+
)
|
|
1187
|
+
const mentionTokens = knownSlugs.length > 0
|
|
1188
|
+
? findCharacterMentionTokens(prompt, knownSlugs)
|
|
1189
|
+
: []
|
|
1190
|
+
// url → tile id, populated only for mentioned variants.
|
|
1191
|
+
const mentionUrlToId = new Map<string, string>()
|
|
1192
|
+
const bySlug = new Map<string, ConnectedReference>()
|
|
1193
|
+
const byVariant = new Map<string, ConnectedReference>()
|
|
1194
|
+
for (const r of refs) {
|
|
1195
|
+
if (!r.characterSlug) continue
|
|
1196
|
+
if (!r.variantSlug) {
|
|
1197
|
+
if (!bySlug.has(r.characterSlug)) bySlug.set(r.characterSlug, r)
|
|
1198
|
+
} else {
|
|
1199
|
+
byVariant.set(`${r.characterSlug}:${r.variantSlug}`, r)
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
for (const t of mentionTokens) {
|
|
1203
|
+
const match = t.variantSlug
|
|
1204
|
+
? byVariant.get(`${t.characterSlug}:${t.variantSlug}`)
|
|
1205
|
+
: bySlug.get(t.characterSlug)
|
|
1206
|
+
if (!match?.url) continue
|
|
1207
|
+
if (!mentionUrlToId.has(match.url)) {
|
|
1208
|
+
mentionUrlToId.set(match.url, `mention:${t.characterSlug}:${t.variantSlug || "canonical"}`)
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// url → first matching ref (used for both canonical and wired-raw paths).
|
|
1213
|
+
const firstRefByUrl = new Map<string, ConnectedReference>()
|
|
1214
|
+
for (const r of refs) {
|
|
1215
|
+
if (!r.url) continue
|
|
1216
|
+
if (!firstRefByUrl.has(r.url)) firstRefByUrl.set(r.url, r)
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
return urls.map((url) => {
|
|
1220
|
+
const mentionId = mentionUrlToId.get(url)
|
|
1221
|
+
if (mentionId) return mentionId
|
|
1222
|
+
const ref = firstRefByUrl.get(url)
|
|
1223
|
+
if (!ref) return null
|
|
1224
|
+
// Extra refs use their stable id (set by the consumer panel).
|
|
1225
|
+
if (ref.isExtraRef) {
|
|
1226
|
+
return `wired:${sourceNodeIdById?.get(ref.id) ?? ref.id}`
|
|
1227
|
+
}
|
|
1228
|
+
if (ref.source === "wired-character" && ref.characterSlug) {
|
|
1229
|
+
// Canonical entry (no variant slug) when reached via the fallback path.
|
|
1230
|
+
// Variant entries are handled by the mention path above.
|
|
1231
|
+
if (!ref.variantSlug) {
|
|
1232
|
+
return `char-canonical:${ref.characterSlug}`
|
|
1233
|
+
}
|
|
1234
|
+
// A wired-character VARIANT URL with no mention shouldn't appear in the
|
|
1235
|
+
// final URL list under normal flow, but handle it defensively.
|
|
1236
|
+
return `mention:${ref.characterSlug}:${ref.variantSlug}`
|
|
1237
|
+
}
|
|
1238
|
+
// Non-character wired or manual entry.
|
|
1239
|
+
return `wired:${sourceNodeIdById?.get(ref.id) ?? ref.id}`
|
|
1240
|
+
})
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Reorder URLs by `referenceOrder` AND renumber `Image N` tokens in the
|
|
1245
|
+
* prompt to match. Returns the new URL list + the rewritten prompt. When
|
|
1246
|
+
* `referenceOrder` is empty / null / all-stale, the original URLs + prompt
|
|
1247
|
+
* are returned unchanged (no-op contract).
|
|
1248
|
+
*
|
|
1249
|
+
* Renumbering rule: build an `original-pos → new-pos` map from the URL move
|
|
1250
|
+
* indices, then regex-replace `Image N` substrings (word-boundary-anchored
|
|
1251
|
+
* so we don't touch "Image 12" while remapping "Image 1"). Done in one pass
|
|
1252
|
+
* via a callback that looks up each match in the map; unknown positions are
|
|
1253
|
+
* left as-is.
|
|
1254
|
+
*
|
|
1255
|
+
* NB: `findCharacterMentionTokens` is already applied BEFORE this step, so
|
|
1256
|
+
* any `@kira:1:smile` literals in the prompt have been replaced with display
|
|
1257
|
+
* names — only positional `Image N` markers remain to be remapped.
|
|
1258
|
+
*
|
|
1259
|
+
* Exposed (non-export internal) — also called by video branches in
|
|
1260
|
+
* payload-builder / execute-node via `applyReferenceOrderToVideo` so video
|
|
1261
|
+
* prompts honor the same reorder semantics as image prompts.
|
|
1262
|
+
*/
|
|
1263
|
+
export function applyReferenceOrderToVideo(
|
|
1264
|
+
urls: readonly string[],
|
|
1265
|
+
prompt: string | undefined,
|
|
1266
|
+
refs: readonly ConnectedReference[],
|
|
1267
|
+
referenceOrder: readonly string[] | undefined,
|
|
1268
|
+
sourceNodeIdById?: ReadonlyMap<string, string>,
|
|
1269
|
+
/**
|
|
1270
|
+
* Number of LEADING reference images (e.g. plain image-refs that precede the
|
|
1271
|
+
* asset URLs in the unified `@image_N` numbering — see
|
|
1272
|
+
* `resolveVideoReferenceCore`'s `leadingRefUrls`). `urls` here are the ASSET
|
|
1273
|
+
* URLs only; their prompt ordinals are `ordinalOffset + 1 …`, so the renumber
|
|
1274
|
+
* remap is keyed by the offset ordinal and leading ordinals (`1 … offset`)
|
|
1275
|
+
* pass through untouched. Defaults to 0 → behaviour unchanged.
|
|
1276
|
+
*/
|
|
1277
|
+
ordinalOffset = 0,
|
|
1278
|
+
): { urls: string[]; prompt: string | undefined } {
|
|
1279
|
+
if (!referenceOrder || !referenceOrder.length || urls.length < 2) {
|
|
1280
|
+
return { urls: [...urls], prompt }
|
|
1281
|
+
}
|
|
1282
|
+
const reordered = applyReferenceOrder(
|
|
1283
|
+
urls,
|
|
1284
|
+
prompt ?? "",
|
|
1285
|
+
refs,
|
|
1286
|
+
referenceOrder,
|
|
1287
|
+
sourceNodeIdById,
|
|
1288
|
+
ordinalOffset,
|
|
1289
|
+
)
|
|
1290
|
+
return {
|
|
1291
|
+
urls: reordered.urls,
|
|
1292
|
+
prompt: prompt === undefined ? undefined : reordered.prompt,
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function applyReferenceOrder(
|
|
1297
|
+
urls: readonly string[],
|
|
1298
|
+
prompt: string,
|
|
1299
|
+
refs: readonly ConnectedReference[],
|
|
1300
|
+
referenceOrder: readonly string[],
|
|
1301
|
+
sourceNodeIdById?: ReadonlyMap<string, string>,
|
|
1302
|
+
ordinalOffset = 0,
|
|
1303
|
+
): { urls: string[]; prompt: string } {
|
|
1304
|
+
if (!referenceOrder.length) return { urls: [...urls], prompt }
|
|
1305
|
+
const tileIds = buildTileIdForUrl(urls, refs, prompt, sourceNodeIdById)
|
|
1306
|
+
// Map: tile id → original index in URLs array. Keeps first occurrence per
|
|
1307
|
+
// tile id (URLs are already deduped by `buildImagePrompt`'s merge step).
|
|
1308
|
+
const byTileId = new Map<string, number>()
|
|
1309
|
+
for (let i = 0; i < tileIds.length; i++) {
|
|
1310
|
+
const id = tileIds[i]
|
|
1311
|
+
if (id && !byTileId.has(id)) byTileId.set(id, i)
|
|
1312
|
+
}
|
|
1313
|
+
const newOrderIndices: number[] = []
|
|
1314
|
+
const seenIndices = new Set<number>()
|
|
1315
|
+
for (const id of referenceOrder) {
|
|
1316
|
+
const idx = byTileId.get(id)
|
|
1317
|
+
if (idx === undefined) continue
|
|
1318
|
+
if (seenIndices.has(idx)) continue
|
|
1319
|
+
newOrderIndices.push(idx)
|
|
1320
|
+
seenIndices.add(idx)
|
|
1321
|
+
}
|
|
1322
|
+
// Append URLs not in `referenceOrder` in their original order.
|
|
1323
|
+
for (let i = 0; i < urls.length; i++) {
|
|
1324
|
+
if (!seenIndices.has(i)) {
|
|
1325
|
+
newOrderIndices.push(i)
|
|
1326
|
+
seenIndices.add(i)
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
// No-op check: if `newOrderIndices` is `[0, 1, 2, …]` we'd rebuild the same
|
|
1330
|
+
// arrays. Cheap to detect, lets us avoid the regex when the user hasn't
|
|
1331
|
+
// actually changed anything.
|
|
1332
|
+
let isNoop = newOrderIndices.length === urls.length
|
|
1333
|
+
for (let i = 0; isNoop && i < newOrderIndices.length; i++) {
|
|
1334
|
+
if (newOrderIndices[i] !== i) isNoop = false
|
|
1335
|
+
}
|
|
1336
|
+
if (isNoop) return { urls: [...urls], prompt }
|
|
1337
|
+
const newUrls = newOrderIndices.map((i) => urls[i])
|
|
1338
|
+
// Build orig-1based-pos → new-1based-pos map for prompt renumbering. Offset by
|
|
1339
|
+
// `ordinalOffset` so when `urls` are the ASSET tail of a unified list (leading
|
|
1340
|
+
// image-refs occupy `1 … ordinalOffset`), the asset ordinals `ordinalOffset+1 …`
|
|
1341
|
+
// remap among themselves and the leading ordinals are never matched (their keys
|
|
1342
|
+
// aren't in `remap`, so the regex leaves them untouched).
|
|
1343
|
+
const remap = new Map<number, number>()
|
|
1344
|
+
for (let newPos = 0; newPos < newOrderIndices.length; newPos++) {
|
|
1345
|
+
remap.set(ordinalOffset + newOrderIndices[newPos] + 1, ordinalOffset + newPos + 1)
|
|
1346
|
+
}
|
|
1347
|
+
// Replace every positional ordinal (1-3 digit, word-boundary) substring,
|
|
1348
|
+
// preserving its prefix: image prompts use `Image N`, video prompts use the
|
|
1349
|
+
// `@image_N` binding form (REF_BINDING.ordinal). The alternation is
|
|
1350
|
+
// case-exact (no `i` flag) so each form re-emits with the SAME prefix.
|
|
1351
|
+
// Negative lookahead on digits guards against `Image 12` → `Image 32` when
|
|
1352
|
+
// remapping 1 → 3. The lookbehind prevents catching `XImage 1` / `foo@image_1`.
|
|
1353
|
+
const renumbered = prompt.replace(/(?<![A-Za-z])(@image_|Image )(\d{1,3})(?!\d)/g, (whole, prefix, n) => {
|
|
1354
|
+
const orig = parseInt(n, 10)
|
|
1355
|
+
const next = remap.get(orig)
|
|
1356
|
+
if (next === undefined || next === orig) return whole
|
|
1357
|
+
return `${prefix}${next}`
|
|
1358
|
+
})
|
|
1359
|
+
return { urls: newUrls, prompt: renumbered }
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function buildCharacterPositionMap(
|
|
1363
|
+
mergedUrls: readonly string[],
|
|
1364
|
+
refs: readonly ConnectedReference[],
|
|
1365
|
+
): Map<string, number> {
|
|
1366
|
+
const byUrl = new Map<string, ConnectedReference>()
|
|
1367
|
+
for (const r of refs) {
|
|
1368
|
+
if (!r.url) continue
|
|
1369
|
+
if (!byUrl.has(r.url)) byUrl.set(r.url, r)
|
|
1370
|
+
}
|
|
1371
|
+
const positions = new Map<string, number>()
|
|
1372
|
+
for (let i = 0; i < mergedUrls.length; i++) {
|
|
1373
|
+
const ref = byUrl.get(mergedUrls[i])
|
|
1374
|
+
const slug = ref?.characterSlug
|
|
1375
|
+
if (slug && !positions.has(slug)) {
|
|
1376
|
+
positions.set(slug, i + 1)
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return positions
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
export interface BuildImagePromptConfig {
|
|
1383
|
+
/** Raw user prompt text */
|
|
1384
|
+
prompt: string
|
|
1385
|
+
/** Image provider key (e.g. "nano-banana", "gpt-image") */
|
|
1386
|
+
provider: string
|
|
1387
|
+
/** Style text to append (e.g. "cinematic") */
|
|
1388
|
+
style?: string
|
|
1389
|
+
/** Negative prompt text */
|
|
1390
|
+
negativePrompt?: string
|
|
1391
|
+
/** Character definitions selected for this node */
|
|
1392
|
+
characterDefs?: CharacterDef[]
|
|
1393
|
+
/** User-level prompt template overrides */
|
|
1394
|
+
userTemplates?: Record<string, string>
|
|
1395
|
+
/** Flow-level prompt template overrides */
|
|
1396
|
+
flowTemplates?: Record<string, string>
|
|
1397
|
+
/** Reference image URLs from direct connections, extracted refs, and character refs */
|
|
1398
|
+
referenceImageUrls?: string[]
|
|
1399
|
+
/** Ancestor reference image URLs (fallback when no direct refs exist) */
|
|
1400
|
+
ancestorRefs?: string[]
|
|
1401
|
+
/**
|
|
1402
|
+
* Rich connected-reference data. When provided, supersedes `characterDefs`
|
|
1403
|
+
* and `referenceImageUrls`: per-identity directives come from this list +
|
|
1404
|
+
* any `{image:N:label}` mentions in the prompt, URLs come from this list
|
|
1405
|
+
* in order, and tokens expand to "the {label} from image {N}".
|
|
1406
|
+
*/
|
|
1407
|
+
connectedReferences?: ConnectedReference[]
|
|
1408
|
+
/** Per-identity (imageIndex+label) user overrides for fidelity / custom text. */
|
|
1409
|
+
identityMeta?: readonly IdentityMeta[]
|
|
1410
|
+
/**
|
|
1411
|
+
* Reference-prompt assembly format for the `{image:N:label}` connected-
|
|
1412
|
+
* reference (non-character) path. Additive + opt-in:
|
|
1413
|
+
* - "legacy" (default): the "Use these references:/Compose them naturally:"
|
|
1414
|
+
* wrap with numeric `Image N` directives — unchanged behavior.
|
|
1415
|
+
* - "hybrid": token expansion only — every `{image:N:label}` token → "the
|
|
1416
|
+
* <label> from reference image <LETTER>". NO lock snippet is auto-injected
|
|
1417
|
+
* (authors prepend their own). Images-only for now; characters/objects/
|
|
1418
|
+
* locations still render legacy. v1 skips `referenceOrder` renumbering.
|
|
1419
|
+
*/
|
|
1420
|
+
referenceFormat?: "legacy" | "hybrid"
|
|
1421
|
+
/**
|
|
1422
|
+
* OPTIONAL reference-lock snippet to prepend ahead of the hybrid scene. Only
|
|
1423
|
+
* used when `referenceFormat === "hybrid"`. Nothing is prepended by default —
|
|
1424
|
+
* authors include their own lock snippet in the prompt text. Provide this only
|
|
1425
|
+
* if a caller wants to auto-prepend a lock.
|
|
1426
|
+
*/
|
|
1427
|
+
referenceLockSnippet?: string
|
|
1428
|
+
/**
|
|
1429
|
+
* User-defined reorder of the injected reference list. Each entry is a
|
|
1430
|
+
* stable tile ID using the scheme from `compute-injected-refs.ts`:
|
|
1431
|
+
* - `wired:<sourceNodeId>` (a wired upstream — manual / wired-image /
|
|
1432
|
+
* wired-face / wired-object / wired-location / extra-ref)
|
|
1433
|
+
* - `mention:<characterSlug>:<variantSlug|canonical>` (an `@-mention`
|
|
1434
|
+
* resolved to a character variant URL)
|
|
1435
|
+
* - `char-canonical:<characterSlug>` (the auto-attached canonical
|
|
1436
|
+
* fallback for a wired character that the user did NOT `@-mention`)
|
|
1437
|
+
*
|
|
1438
|
+
* Behavior: after the existing assembly produces a URL list + a prompt
|
|
1439
|
+
* with `Image N` directives, the URLs are re-ordered to match this list
|
|
1440
|
+
* AND every `Image N` token in the prompt is renumbered consistently
|
|
1441
|
+
* (so directive bullets, the user's typed `Image N` references, and the
|
|
1442
|
+
* worker `referenceImageUrls` index all agree).
|
|
1443
|
+
*
|
|
1444
|
+
* IDs in this array that don't match any tile are silently dropped. Tiles
|
|
1445
|
+
* whose ID is NOT in this array fall to the end in their natural order.
|
|
1446
|
+
* Identical fixtures on frontend + backend MUST produce identical URL
|
|
1447
|
+
* lists — the helper is shared via `compute-injected-refs.ts`.
|
|
1448
|
+
*
|
|
1449
|
+
* Stable-ID-per-URL mapping for the post-assembly re-order is computed
|
|
1450
|
+
* from `connectedReferences` + the user's prompt; passing `referenceOrder`
|
|
1451
|
+
* is a no-op when `connectedReferences` is missing (legacy path).
|
|
1452
|
+
*
|
|
1453
|
+
* Optional, omit to keep the existing natural order.
|
|
1454
|
+
*/
|
|
1455
|
+
referenceOrder?: readonly string[]
|
|
1456
|
+
/**
|
|
1457
|
+
* Map of `connectedReferences[i].id → sourceNodeId` so wired-raw tile IDs
|
|
1458
|
+
* match the upstream node IDs the consumer panel exposes. When omitted, the
|
|
1459
|
+
* builder falls back to `connectedReferences[i].id` (which equals the
|
|
1460
|
+
* upstream node ID for wired entries built by the existing image-configs +
|
|
1461
|
+
* payload-builder flow).
|
|
1462
|
+
*/
|
|
1463
|
+
sourceNodeIdById?: ReadonlyMap<string, string>
|
|
1464
|
+
/**
|
|
1465
|
+
* Character slugs whose canonical-fallback the user has explicitly hidden
|
|
1466
|
+
* via the × button. Mention URLs for the same character still attach.
|
|
1467
|
+
*/
|
|
1468
|
+
suppressedCanonicalCharacterIds?: readonly string[]
|
|
1469
|
+
/**
|
|
1470
|
+
* Location slugs (or DB ids) whose canonical-fallback the user has hidden
|
|
1471
|
+
* via the × button. Mirrors `suppressedCanonicalCharacterIds`: the upstream
|
|
1472
|
+
* `wired-location` ref still attaches, but the canonical establishing-shot
|
|
1473
|
+
* URL is dropped from the injected reference list.
|
|
1474
|
+
*
|
|
1475
|
+
* NOTE (Phase 1A): the location canonical-fallback path is wired up in a
|
|
1476
|
+
* follow-up PR (`injected-reference-helpers.ts`). Until then, this
|
|
1477
|
+
* parameter is accepted but inert — callers can pass it through without
|
|
1478
|
+
* any behavior change. Once the canonical-fallback logic lands, the
|
|
1479
|
+
* builder will filter `connectedReferences` with `source === "wired-location"`
|
|
1480
|
+
* and a matching slug, exactly like the character path does.
|
|
1481
|
+
*/
|
|
1482
|
+
suppressedCanonicalLocationIds?: readonly string[]
|
|
1483
|
+
/**
|
|
1484
|
+
* When true, skip the entire mention-resolution block (character identity
|
|
1485
|
+
* directives, `Image N (Kira)` bullets, additional ref URLs from
|
|
1486
|
+
* `connectedReferences`) AND strip raw `@slug[:V[:variant]]` tokens from
|
|
1487
|
+
* the prompt. Used by the LoRA inference path
|
|
1488
|
+
* (`flux-lora-character`) — the trigger word + LoRA carries identity, so
|
|
1489
|
+
* the directive bullets are redundant and the wired-character refs
|
|
1490
|
+
* shouldn't be injected as `Image N`.
|
|
1491
|
+
*/
|
|
1492
|
+
skipCharacterMentions?: boolean
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
export interface BuildImagePromptResult {
|
|
1496
|
+
/** Final assembled prompt */
|
|
1497
|
+
prompt: string
|
|
1498
|
+
/** Native negative prompt (only for models that support it), undefined otherwise */
|
|
1499
|
+
nativeNegativePrompt: string | undefined
|
|
1500
|
+
/** Filtered reference image URLs (only for models that support them) */
|
|
1501
|
+
referenceImageUrls: string[] | undefined
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
export type PromptSegmentOrigin =
|
|
1505
|
+
| "user" // typed by the user
|
|
1506
|
+
| "variable" // resolved {Node Label} value
|
|
1507
|
+
| "picker" // parameter-picker / cinematography fragment
|
|
1508
|
+
| "mention" // identity/reference directive block
|
|
1509
|
+
| "style" // auto-appended Style suffix
|
|
1510
|
+
| "negative" // auto-appended Avoid suffix
|
|
1511
|
+
|
|
1512
|
+
export interface PromptSegment {
|
|
1513
|
+
readonly text: string
|
|
1514
|
+
readonly origin: PromptSegmentOrigin
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
export interface BuildImagePromptSegmentsResult extends BuildImagePromptResult {
|
|
1518
|
+
/** Origin-tagged decomposition of `prompt`. INVARIANT (tested):
|
|
1519
|
+
* segments.map(s => s.text).join("") === prompt. */
|
|
1520
|
+
segments: PromptSegment[]
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Internal capture surface for `buildImagePromptSegments`. `buildImagePrompt`
|
|
1525
|
+
* itself never passes this — its output is byte-identical with or without it.
|
|
1526
|
+
* Each field records a span produced during final assembly so the segment
|
|
1527
|
+
* decomposition can be reconstructed without re-deriving the string:
|
|
1528
|
+
* - `directivesPrefix`: the directive block PREPENDED ahead of the user body
|
|
1529
|
+
* (connectedReferences path, "Use these references…/Compose them…" wrap).
|
|
1530
|
+
* Empty when the directive block was spliced mid-string into a Phase-0
|
|
1531
|
+
* "Use these characters:" block (documented degradation → body collapses).
|
|
1532
|
+
* - `bodyBeforeSuffixes`: the prompt string captured immediately BEFORE the
|
|
1533
|
+
* style/avoid suffixes were appended (covers the common no-truncation case).
|
|
1534
|
+
* - `styleSuffix` / `avoidSuffix`: the `\nStyle: …` / `\nAvoid: …` strings.
|
|
1535
|
+
*/
|
|
1536
|
+
interface AssemblyMarks {
|
|
1537
|
+
directivesPrefix: string
|
|
1538
|
+
bodyBeforeSuffixes: string
|
|
1539
|
+
styleSuffix: string
|
|
1540
|
+
avoidSuffix: string
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** Keep `bodySegments` when the assembled body still equals their join;
|
|
1544
|
+
* otherwise collapse to one `user` segment (assembly rewrote the body —
|
|
1545
|
+
* mention replacement, {image:N} expansion, truncation, or reorder). */
|
|
1546
|
+
function reconcileBodySegments(body: string, bodySegments: readonly PromptSegment[] | undefined): PromptSegment[] {
|
|
1547
|
+
if (!body) return []
|
|
1548
|
+
if (bodySegments && bodySegments.map((s) => s.text).join("") === body) {
|
|
1549
|
+
return [...bodySegments]
|
|
1550
|
+
}
|
|
1551
|
+
return [{ text: body, origin: "user" }]
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Build the final image generation prompt from config.
|
|
1556
|
+
* Handles character description wrapping, style appending, negative prompt routing,
|
|
1557
|
+
* truncation, and reference image filtering.
|
|
1558
|
+
*
|
|
1559
|
+
* Thin passthrough over `buildImagePromptInternal` — byte-identical behavior,
|
|
1560
|
+
* no marks captured. Use `buildImagePromptSegments` when you also need the
|
|
1561
|
+
* origin-tagged decomposition.
|
|
1562
|
+
*/
|
|
1563
|
+
export function buildImagePrompt(config: BuildImagePromptConfig): BuildImagePromptResult {
|
|
1564
|
+
return buildImagePromptInternal(config)
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Shared assembly body for `buildImagePrompt` + `buildImagePromptSegments`.
|
|
1569
|
+
* When `marks` is provided, records the directive-prefix / body / suffix spans
|
|
1570
|
+
* (guarded by `if (marks)`) so callers can reconstruct an origin-tagged
|
|
1571
|
+
* decomposition. The string output is identical regardless of `marks`.
|
|
1572
|
+
*/
|
|
1573
|
+
function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: AssemblyMarks): BuildImagePromptResult {
|
|
1574
|
+
let {
|
|
1575
|
+
provider,
|
|
1576
|
+
style,
|
|
1577
|
+
negativePrompt,
|
|
1578
|
+
characterDefs = [],
|
|
1579
|
+
userTemplates,
|
|
1580
|
+
flowTemplates,
|
|
1581
|
+
referenceImageUrls = [],
|
|
1582
|
+
ancestorRefs = [],
|
|
1583
|
+
connectedReferences,
|
|
1584
|
+
identityMeta = [],
|
|
1585
|
+
} = config
|
|
1586
|
+
const referenceOrder = config.referenceOrder ?? []
|
|
1587
|
+
const sourceNodeIdById = config.sourceNodeIdById
|
|
1588
|
+
const suppressedCanonicalCharacterIds = new Set(
|
|
1589
|
+
config.suppressedCanonicalCharacterIds ?? [],
|
|
1590
|
+
)
|
|
1591
|
+
const suppressedCanonicalLocationIds = config.suppressedCanonicalLocationIds ?? []
|
|
1592
|
+
// `referenceFormat` is preserved across the Phase-0 `config = {...config}`
|
|
1593
|
+
// reassignment, so computing this once up front is stable. Gates the hybrid
|
|
1594
|
+
// character convergence (Phase 0) and the non-capitalizing scene render below.
|
|
1595
|
+
const isHybrid = config.referenceFormat === "hybrid"
|
|
1596
|
+
// Set when Phase 0 converged character OR location @-mentions into inline
|
|
1597
|
+
// hybrid role phrases (+ identity-lock + element directives). Tells the hybrid
|
|
1598
|
+
// scene render to expand any remaining {image:N} tokens WITHOUT capitalizing
|
|
1599
|
+
// line-initials (which would corrupt "the face from reference image A" → "The
|
|
1600
|
+
// face …" / "the style from reference image A" → "The style …").
|
|
1601
|
+
let hybridBodyConverged = false
|
|
1602
|
+
|
|
1603
|
+
// Character LoRA inference path: trigger word + LoRA model carry identity,
|
|
1604
|
+
// so we strip raw `@slug[:V[:variant]]` tokens from the prompt AND drop the
|
|
1605
|
+
// connected-reference machinery entirely (no Image N bullets, no
|
|
1606
|
+
// ref URLs). The LoRA model gets a clean prompt + the trigger word
|
|
1607
|
+
// injected by the Replicate buildInput (see providers/replicate/image.ts).
|
|
1608
|
+
if (config.skipCharacterMentions) {
|
|
1609
|
+
config = {
|
|
1610
|
+
...config,
|
|
1611
|
+
prompt: config.prompt.replace(
|
|
1612
|
+
/@[a-z0-9_-]+(?::\d+(?::[a-z0-9_-]+)?)?\s?/gi,
|
|
1613
|
+
"",
|
|
1614
|
+
).trim(),
|
|
1615
|
+
}
|
|
1616
|
+
connectedReferences = undefined
|
|
1617
|
+
referenceImageUrls = []
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// Apply canonical suppression UP FRONT so `buildCanonicalFallback` never
|
|
1621
|
+
// sees the suppressed slugs. This is the only way to drop both the URL
|
|
1622
|
+
// and the directive line; filtering after the fact would leave the
|
|
1623
|
+
// directive numbering misaligned.
|
|
1624
|
+
if (
|
|
1625
|
+
connectedReferences
|
|
1626
|
+
&& suppressedCanonicalCharacterIds.size > 0
|
|
1627
|
+
) {
|
|
1628
|
+
connectedReferences = connectedReferences.filter((r) => {
|
|
1629
|
+
if (r.source !== "wired-character") return true
|
|
1630
|
+
if (!r.characterSlug) return true
|
|
1631
|
+
// Only drop the CANONICAL entry — `@-mentioned` variants are explicit
|
|
1632
|
+
// and should stay even when canonical is suppressed.
|
|
1633
|
+
if (r.variantSlug) return true
|
|
1634
|
+
if (r.isExtraRef) return true
|
|
1635
|
+
return !suppressedCanonicalCharacterIds.has(r.characterSlug)
|
|
1636
|
+
})
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// Cap structured references to the provider's image-reference limit — the SAME
|
|
1640
|
+
// cap the canvas enforces via handle-limits. Applied UP FRONT (like the
|
|
1641
|
+
// suppression filter above) so the direct API / MCP / SDK path can't send more
|
|
1642
|
+
// references than the provider accepts and leave an `Image N` directive
|
|
1643
|
+
// pointing at a slot the provider silently drops. Flat `referenceImageUrls` are
|
|
1644
|
+
// numbered first, so they consume the budget. Only ref-capable providers
|
|
1645
|
+
// (refCap > 0) cap here; non-ref providers drop all URLs via `supportsRefs`.
|
|
1646
|
+
if (connectedReferences && connectedReferences.length > 0) {
|
|
1647
|
+
const refCap = imageReferenceLimit(provider)
|
|
1648
|
+
if (refCap > 0) {
|
|
1649
|
+
const structuredBudget = Math.max(0, refCap - referenceImageUrls.length)
|
|
1650
|
+
if (connectedReferences.length > structuredBudget) {
|
|
1651
|
+
connectedReferences = connectedReferences.slice(0, structuredBudget)
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// -------------------------------------------------------------------------
|
|
1657
|
+
// Phase 0: resolve `@<character-slug>:<index>(:<variant-slug>)?` tokens
|
|
1658
|
+
// AND apply the per-character canonical fallback for unmentioned wired
|
|
1659
|
+
// characters. Runs BEFORE the existing `{image:N}` resolution path. Both
|
|
1660
|
+
// coexist: Phase 0 handles slug-based character mentions / fallbacks;
|
|
1661
|
+
// the existing path below handles `{image:N:label}` tokens for
|
|
1662
|
+
// non-character refs.
|
|
1663
|
+
//
|
|
1664
|
+
// Per-character contract:
|
|
1665
|
+
// - wired-character WITHOUT any `@-mention` → contribute canonical URL
|
|
1666
|
+
// + a strong identity directive (pre-mention-feature behavior).
|
|
1667
|
+
// - wired-character WITH at least one `@-mention` → contribute ONLY
|
|
1668
|
+
// mentioned variant URLs (no canonical auto-attach).
|
|
1669
|
+
// -------------------------------------------------------------------------
|
|
1670
|
+
if (connectedReferences && connectedReferences.length > 0) {
|
|
1671
|
+
const knownCharacterSlugs = Array.from(
|
|
1672
|
+
new Set(
|
|
1673
|
+
connectedReferences
|
|
1674
|
+
.map((r) => r.characterSlug)
|
|
1675
|
+
.filter((s): s is string => typeof s === "string" && s.length > 0)
|
|
1676
|
+
)
|
|
1677
|
+
)
|
|
1678
|
+
const hasExtraRefs = connectedReferences.some((r) => r.isExtraRef === true)
|
|
1679
|
+
// We only need to run the directive-emission machinery if either
|
|
1680
|
+
// (a) there's at least one character ref (mention / canonical fallback
|
|
1681
|
+
// could fire), or (b) there's at least one user-attached extra ref
|
|
1682
|
+
// (manual uploads with descriptions, or character-variant extras).
|
|
1683
|
+
// The empty-extras-empty-characters case falls through to the standard
|
|
1684
|
+
// non-character ref path unchanged.
|
|
1685
|
+
// Known location slugs (Phase 2 #2) — separate from character slugs.
|
|
1686
|
+
// Each location row contributes a canonical entry + per-variant entries,
|
|
1687
|
+
// all sharing the same `locationSlug`; we want the unique set for finder.
|
|
1688
|
+
const knownLocationSlugs = Array.from(
|
|
1689
|
+
new Set(
|
|
1690
|
+
connectedReferences
|
|
1691
|
+
.map((r) => r.locationSlug)
|
|
1692
|
+
.filter((s): s is string => typeof s === "string" && s.length > 0)
|
|
1693
|
+
)
|
|
1694
|
+
)
|
|
1695
|
+
if (knownCharacterSlugs.length > 0 || hasExtraRefs || knownLocationSlugs.length > 0) {
|
|
1696
|
+
const mentionTokens = knownCharacterSlugs.length > 0
|
|
1697
|
+
? findCharacterMentionTokens(config.prompt, knownCharacterSlugs)
|
|
1698
|
+
: []
|
|
1699
|
+
// Character @-mention resolution. HYBRID (Unified Reference Roles,
|
|
1700
|
+
// Phase A): each mention becomes the inline role phrase "the {role} from
|
|
1701
|
+
// reference image {LETTER}" with the optional identity-lock + wired
|
|
1702
|
+
// elementInjection surfaced SEPARATELY, and NO "Use these characters:"
|
|
1703
|
+
// block. LEGACY keeps the bullet block (and is shared verbatim with the
|
|
1704
|
+
// video resolver — untouched here).
|
|
1705
|
+
let hybridLockLines: string[] = []
|
|
1706
|
+
let hybridElementDirectives: string[] = []
|
|
1707
|
+
let resolved: ResolveCharacterMentionsResult
|
|
1708
|
+
if (isHybrid && mentionTokens.length > 0) {
|
|
1709
|
+
const h = resolveCharacterMentionsHybrid(
|
|
1710
|
+
config.prompt,
|
|
1711
|
+
mentionTokens,
|
|
1712
|
+
connectedReferences,
|
|
1713
|
+
referenceImageUrls,
|
|
1714
|
+
)
|
|
1715
|
+
resolved = {
|
|
1716
|
+
prompt: h.prompt,
|
|
1717
|
+
additionalUrls: h.additionalUrls,
|
|
1718
|
+
mentionedCharacterSlugs: h.mentionedCharacterSlugs,
|
|
1719
|
+
}
|
|
1720
|
+
hybridLockLines = h.lockLines
|
|
1721
|
+
hybridElementDirectives = h.elementDirectives
|
|
1722
|
+
hybridBodyConverged = true
|
|
1723
|
+
} else {
|
|
1724
|
+
resolved = mentionTokens.length > 0
|
|
1725
|
+
? resolveCharacterMentions(config.prompt, mentionTokens, connectedReferences)
|
|
1726
|
+
: { prompt: config.prompt, additionalUrls: [], mentionedCharacterSlugs: new Set<string>() }
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// Phase 2 #2: resolve `@oldlibrary:1:weather/rain` location mentions on
|
|
1730
|
+
// the post-character-resolution prompt. Character resolution may have
|
|
1731
|
+
// already swapped its own @-tokens for display names; location tokens
|
|
1732
|
+
// (different slug namespace) remain intact for this pass.
|
|
1733
|
+
const locationTokens = knownLocationSlugs.length > 0
|
|
1734
|
+
? findLocationMentionTokens(resolved.prompt, knownLocationSlugs)
|
|
1735
|
+
: []
|
|
1736
|
+
// Location mention convergence. HYBRID (Unified Reference Roles, Phase C):
|
|
1737
|
+
// each `@location` mention becomes the inline role phrase "the {role} from
|
|
1738
|
+
// reference image {LETTER}" (role from the location node's usage mode) with
|
|
1739
|
+
// the opt-in lock + wired elementInjection surfaced SEPARATELY, and NO "Use
|
|
1740
|
+
// these locations:" block. LEGACY keeps the bullet block. `existingUrls` =
|
|
1741
|
+
// [base refs, resolved CHARACTER mentions] so location slot letters are a
|
|
1742
|
+
// prefix of the final `finalIndexByUrl` (location URLs merge after character
|
|
1743
|
+
// mentions, before the character canonical/extra URLs).
|
|
1744
|
+
let hybridLocationLockLines: string[] = []
|
|
1745
|
+
let hybridLocationElementDirectives: string[] = []
|
|
1746
|
+
let locationResolved: ResolveLocationMentionsResult
|
|
1747
|
+
if (isHybrid && locationTokens.length > 0) {
|
|
1748
|
+
const hl = resolveLocationMentionsHybrid(
|
|
1749
|
+
resolved.prompt,
|
|
1750
|
+
locationTokens,
|
|
1751
|
+
connectedReferences,
|
|
1752
|
+
[...(referenceImageUrls || []), ...resolved.additionalUrls],
|
|
1753
|
+
)
|
|
1754
|
+
locationResolved = {
|
|
1755
|
+
prompt: hl.prompt,
|
|
1756
|
+
additionalUrls: hl.additionalUrls,
|
|
1757
|
+
mentionedLocationSlugs: hl.mentionedLocationSlugs,
|
|
1758
|
+
}
|
|
1759
|
+
hybridLocationLockLines = hl.lockLines
|
|
1760
|
+
hybridLocationElementDirectives = hl.elementDirectives
|
|
1761
|
+
// Inline role phrases now live in the body → skip line-initial
|
|
1762
|
+
// capitalization. Only when a mention actually matched + was replaced.
|
|
1763
|
+
if (hl.additionalUrls.length > 0) hybridBodyConverged = true
|
|
1764
|
+
} else {
|
|
1765
|
+
locationResolved = locationTokens.length > 0
|
|
1766
|
+
? resolveLocationMentions(resolved.prompt, locationTokens, connectedReferences)
|
|
1767
|
+
: { prompt: resolved.prompt, additionalUrls: [] as string[], mentionedLocationSlugs: new Set<string>() }
|
|
1768
|
+
}
|
|
1769
|
+
// Merge location's resolved prompt + URLs back into `resolved` so the
|
|
1770
|
+
// rest of the pipeline (fallback, extras, urlsByOrder) doesn't need
|
|
1771
|
+
// separate plumbing.
|
|
1772
|
+
resolved.prompt = locationResolved.prompt
|
|
1773
|
+
resolved.additionalUrls = [...resolved.additionalUrls, ...locationResolved.additionalUrls]
|
|
1774
|
+
// Per-variant location refs are mention-only — they were already
|
|
1775
|
+
// attached via `additionalUrls` when matched, and should NEVER
|
|
1776
|
+
// auto-attach via the non-character ref path below (line 1112).
|
|
1777
|
+
// Canonical refs whose slug was mentioned are also handled by the
|
|
1778
|
+
// resolver — drop to avoid double-attaching the canonical URL when the
|
|
1779
|
+
// user explicitly pinned a variant via `@-mention`.
|
|
1780
|
+
const mentionedLocationSlugs = locationResolved.mentionedLocationSlugs
|
|
1781
|
+
connectedReferences = connectedReferences.filter((r) => {
|
|
1782
|
+
if (r.source !== "wired-location") return true
|
|
1783
|
+
if (!r.locationSlug) return true
|
|
1784
|
+
if (r.locationVariantBucket) return false
|
|
1785
|
+
return !mentionedLocationSlugs.has(r.locationSlug)
|
|
1786
|
+
})
|
|
1787
|
+
// Default-fallback canonical URLs + directives for any wired character
|
|
1788
|
+
// that has zero mentions in the prompt. Mirrors the legacy behavior the
|
|
1789
|
+
// mention feature replaced — wire a character with no typing required.
|
|
1790
|
+
//
|
|
1791
|
+
// `startIndex` = position (1-based) of the first canonical fallback
|
|
1792
|
+
// URL in the final merged list. Computed by deduping the prefix
|
|
1793
|
+
// (pre-existing refs + mention URLs) so that if a mention URL
|
|
1794
|
+
// coincidentally equals an upstream ref, the canonical lines still
|
|
1795
|
+
// point at the correct position.
|
|
1796
|
+
const prefixDedupedLength = ([
|
|
1797
|
+
...(referenceImageUrls || []),
|
|
1798
|
+
...resolved.additionalUrls,
|
|
1799
|
+
].filter((u, i, a) => a.indexOf(u) === i)).length
|
|
1800
|
+
// Canonical fallback (unmentioned wired character → auto-attached URL).
|
|
1801
|
+
// LEGACY emits the "Use these characters:" bullet block; HYBRID (Task 4)
|
|
1802
|
+
// attaches the SAME canonical URLs but renders each as an inline role
|
|
1803
|
+
// phrase + opt-in lock below (once the slot letters are known). Selection
|
|
1804
|
+
// is shared so both paths auto-attach the same characters.
|
|
1805
|
+
const canonicalFallbackRefs = isHybrid
|
|
1806
|
+
? selectCanonicalFallbackRefs(connectedReferences, resolved.mentionedCharacterSlugs)
|
|
1807
|
+
: []
|
|
1808
|
+
const fallback = isHybrid
|
|
1809
|
+
? { directiveLines: [] as string[], urls: canonicalFallbackRefs.map((r) => r.url) }
|
|
1810
|
+
: buildCanonicalFallback(
|
|
1811
|
+
connectedReferences,
|
|
1812
|
+
resolved.mentionedCharacterSlugs,
|
|
1813
|
+
prefixDedupedLength + 1,
|
|
1814
|
+
)
|
|
1815
|
+
let promptForNext = resolved.prompt
|
|
1816
|
+
if (fallback.directiveLines.length > 0) {
|
|
1817
|
+
// Mirror `resolveCharacterMentions`'s "Use these characters:\n…" block
|
|
1818
|
+
// structure. When both blocks exist, append fallback bullets to the
|
|
1819
|
+
// same block so the model sees one consolidated header.
|
|
1820
|
+
if (promptForNext.startsWith("Use these characters:\n")) {
|
|
1821
|
+
const splitIdx = promptForNext.indexOf("\n\n")
|
|
1822
|
+
if (splitIdx !== -1) {
|
|
1823
|
+
const header = promptForNext.slice(0, splitIdx)
|
|
1824
|
+
const rest = promptForNext.slice(splitIdx)
|
|
1825
|
+
promptForNext = `${header}\n${fallback.directiveLines.join("\n")}${rest}`
|
|
1826
|
+
} else {
|
|
1827
|
+
promptForNext = `${promptForNext}\n${fallback.directiveLines.join("\n")}`
|
|
1828
|
+
}
|
|
1829
|
+
} else {
|
|
1830
|
+
promptForNext = `Use these characters:\n${fallback.directiveLines.join("\n")}\n\n${promptForNext}`
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
const mergedUrls = [
|
|
1834
|
+
...(referenceImageUrls || []),
|
|
1835
|
+
...resolved.additionalUrls,
|
|
1836
|
+
...fallback.urls,
|
|
1837
|
+
].filter((u, i, a) => a.indexOf(u) === i)
|
|
1838
|
+
|
|
1839
|
+
// Extra-ref directives: user-attached refs (manual uploads + picked
|
|
1840
|
+
// character variants). Emitted AFTER mentions + canonical fallback so
|
|
1841
|
+
// they get the next positional slots. Numbering = position in the final
|
|
1842
|
+
// ref URL list (mergedUrls.length + 1, +2, …).
|
|
1843
|
+
const characterPositions = buildCharacterPositionMap(mergedUrls, connectedReferences)
|
|
1844
|
+
// Extra-refs (manual uploads + picked character variants). LEGACY emits
|
|
1845
|
+
// "Image N" bullets in the "Use these characters:" block; HYBRID (Task 4)
|
|
1846
|
+
// attaches the SAME URLs but renders manual / pair-back directives with
|
|
1847
|
+
// lettered bindings below. Filter shared so both paths attach the same URLs.
|
|
1848
|
+
const extraRefsHybrid = isHybrid
|
|
1849
|
+
? connectedReferences.filter((r) => r.isExtraRef === true && Boolean(r.url))
|
|
1850
|
+
: []
|
|
1851
|
+
const extras = isHybrid
|
|
1852
|
+
? { directiveLines: [] as string[], urls: extraRefsHybrid.map((r) => r.url) }
|
|
1853
|
+
: buildExtraRefDirectives(
|
|
1854
|
+
connectedReferences,
|
|
1855
|
+
characterPositions,
|
|
1856
|
+
mergedUrls.length + 1,
|
|
1857
|
+
)
|
|
1858
|
+
// Always merge extras' URLs — minimal-intervention modes ("none" /
|
|
1859
|
+
// "name") may emit zero or one bullet while still attaching the image.
|
|
1860
|
+
// Gating the URL merge on `directiveLines.length > 0` would silently
|
|
1861
|
+
// drop the URL for a `none`-mode extra.
|
|
1862
|
+
const finalMergedUrls = [...mergedUrls, ...extras.urls]
|
|
1863
|
+
.filter((u, i, a) => a.indexOf(u) === i)
|
|
1864
|
+
if (extras.directiveLines.length > 0) {
|
|
1865
|
+
if (promptForNext.startsWith("Use these characters:\n")) {
|
|
1866
|
+
const splitIdx = promptForNext.indexOf("\n\n")
|
|
1867
|
+
if (splitIdx !== -1) {
|
|
1868
|
+
const header = promptForNext.slice(0, splitIdx)
|
|
1869
|
+
const rest = promptForNext.slice(splitIdx)
|
|
1870
|
+
promptForNext = `${header}\n${extras.directiveLines.join("\n")}${rest}`
|
|
1871
|
+
} else {
|
|
1872
|
+
promptForNext = `${promptForNext}\n${extras.directiveLines.join("\n")}`
|
|
1873
|
+
}
|
|
1874
|
+
} else {
|
|
1875
|
+
promptForNext = `Use these characters:\n${extras.directiveLines.join("\n")}\n\n${promptForNext}`
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// Hybrid assembly. Prepend ONE identity-lock block (mention + canonical
|
|
1880
|
+
// fallback + first-sight extra locks) and append the trailing scene
|
|
1881
|
+
// directives (mention element injections + canonical role phrases +
|
|
1882
|
+
// canonical element injections + extra-ref directives + extra-ref element
|
|
1883
|
+
// injections) — never the legacy "Use these characters:" block.
|
|
1884
|
+
if (isHybrid) {
|
|
1885
|
+
// Unified slot map over the final hybrid URL order
|
|
1886
|
+
// [base, mentions, canonical, extras] — the exact prefix of the "New
|
|
1887
|
+
// path" `finalIndexByUrl`, so every lettered binding agrees byte-for-byte.
|
|
1888
|
+
const slotByUrl = new Map<string, number>()
|
|
1889
|
+
finalMergedUrls.forEach((u, i) => {
|
|
1890
|
+
if (!slotByUrl.has(u)) slotByUrl.set(u, i + 1)
|
|
1891
|
+
})
|
|
1892
|
+
const letterForUrl = (url: string): string => slotToLetter(slotByUrl.get(url) ?? 0)
|
|
1893
|
+
|
|
1894
|
+
// (A) canonical fallback → role phrases + opt-in locks.
|
|
1895
|
+
const canonical = renderCanonicalFallbackHybrid(canonicalFallbackRefs, letterForUrl)
|
|
1896
|
+
// (B) extra-refs → manual / pair-back directives + first-sight locks.
|
|
1897
|
+
// Seed the pair-back lookup with each character's FIRST emitted letter
|
|
1898
|
+
// (mention / canonical fallback), converted from its mergedUrls position.
|
|
1899
|
+
const seedLetterByChar = new Map<string, string>()
|
|
1900
|
+
for (const [slug, pos] of characterPositions) {
|
|
1901
|
+
seedLetterByChar.set(slug, slotToLetter(pos))
|
|
1902
|
+
}
|
|
1903
|
+
const extrasRendered = renderExtraRefsHybrid(extraRefsHybrid, letterForUrl, seedLetterByChar)
|
|
1904
|
+
|
|
1905
|
+
// Role phrases are intentionally lowercase ("the person from reference
|
|
1906
|
+
// image A"); flag the converged state so the scene render below skips
|
|
1907
|
+
// line-initial capitalization (which would corrupt them).
|
|
1908
|
+
if (canonical.phrases.length > 0 || extrasRendered.bodyLines.length > 0) {
|
|
1909
|
+
hybridBodyConverged = true
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// Location MENTION locks/elements (the role phrases themselves are
|
|
1913
|
+
// already inline in `promptForNext`). Mentioned-location URLs sit
|
|
1914
|
+
// between the character mentions and the character canonical/extras in
|
|
1915
|
+
// `finalMergedUrls`, so their letters were computed against the matching
|
|
1916
|
+
// prefix in `resolveLocationMentionsHybrid` and need no re-derivation here.
|
|
1917
|
+
// Set-dedup: a reference can be locked via more than one path (e.g. an
|
|
1918
|
+
// extra whose URL equals the wired canonical is routed to first-sight
|
|
1919
|
+
// by the pair-back letter gate) — identical lock lines say nothing new
|
|
1920
|
+
// to the model, so emit each exact line once. Line texts are
|
|
1921
|
+
// {ref}-bound, so distinct references never collapse.
|
|
1922
|
+
const allLockLines = [...new Set([
|
|
1923
|
+
...hybridLockLines,
|
|
1924
|
+
...hybridLocationLockLines,
|
|
1925
|
+
...canonical.lockLines,
|
|
1926
|
+
...extrasRendered.lockLines,
|
|
1927
|
+
])]
|
|
1928
|
+
const trailingLines = [
|
|
1929
|
+
...hybridElementDirectives,
|
|
1930
|
+
...hybridLocationElementDirectives,
|
|
1931
|
+
...canonical.phrases,
|
|
1932
|
+
...canonical.elementDirectives,
|
|
1933
|
+
...extrasRendered.bodyLines,
|
|
1934
|
+
...extrasRendered.elementDirectives,
|
|
1935
|
+
]
|
|
1936
|
+
const lockBlock = allLockLines.length > 0 ? `${allLockLines.join("\n")}\n\n` : ""
|
|
1937
|
+
const trailingBlock = trailingLines.length > 0 ? `\n${trailingLines.join("\n")}` : ""
|
|
1938
|
+
promptForNext = `${lockBlock}${promptForNext}${trailingBlock}`
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// Mutate the config locals (NOT the original passed config).
|
|
1942
|
+
config = {
|
|
1943
|
+
...config,
|
|
1944
|
+
prompt: promptForNext,
|
|
1945
|
+
referenceImageUrls: finalMergedUrls,
|
|
1946
|
+
}
|
|
1947
|
+
referenceImageUrls = config.referenceImageUrls || []
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// -------------------------------------------------------------------------
|
|
1952
|
+
// New path: rich `connectedReferences` provided. Per-identity directives
|
|
1953
|
+
// are emitted at the top, `{image:N:label}` tokens expand to natural-
|
|
1954
|
+
// language phrases, and URLs are sent in connectedReferences order.
|
|
1955
|
+
//
|
|
1956
|
+
// Character refs (source === "wired-character") are AUTOCOMPLETE-ONLY by
|
|
1957
|
+
// default — they ONLY contribute URLs + directives via Phase 0 mention
|
|
1958
|
+
// resolution above (`referenceImageUrls` and a "Use these characters…"
|
|
1959
|
+
// prefix). A wired character with no @-mention in the prompt contributes
|
|
1960
|
+
// zero URLs and zero directives here. Non-character refs (manual, wired-creature,
|
|
1961
|
+
// wired-image, wired-face, wired-object, wired-location) still auto-
|
|
1962
|
+
// attach so unchanged behavior for them.
|
|
1963
|
+
// -------------------------------------------------------------------------
|
|
1964
|
+
if (connectedReferences) {
|
|
1965
|
+
let prompt = config.prompt
|
|
1966
|
+
|
|
1967
|
+
// Non-character refs are still emitted as per-identity directives + URLs.
|
|
1968
|
+
// Character refs are filtered out here — Phase 0 has already added their
|
|
1969
|
+
// URLs to `referenceImageUrls` (via mention resolution) and prepended the
|
|
1970
|
+
// "Use these characters…" directive block. User-attached extras
|
|
1971
|
+
// (isExtraRef === true) are also filtered out: Phase 0 already emitted
|
|
1972
|
+
// their per-ref directive ("Image N (reference): <description>." / "same
|
|
1973
|
+
// subject as Image M, …") and merged their URLs into `referenceImageUrls`.
|
|
1974
|
+
// Letting them through here would double-emit the URLs (and append a
|
|
1975
|
+
// second positional directive via the {image:N:label} path).
|
|
1976
|
+
const nonCharacterRefs = connectedReferences.filter(
|
|
1977
|
+
(r) => r.source !== "wired-character" && r.isExtraRef !== true,
|
|
1978
|
+
)
|
|
1979
|
+
|
|
1980
|
+
// -----------------------------------------------------------------------
|
|
1981
|
+
// Single source of truth for positional numbering.
|
|
1982
|
+
//
|
|
1983
|
+
// Phase 0 already numbered the character / extra directives against the
|
|
1984
|
+
// order [referenceImageUrls, mentions, canonical-fallback, extras] and
|
|
1985
|
+
// wrote both the bullets and the URLs into `referenceImageUrls`. We APPEND
|
|
1986
|
+
// the non-character URLs after that list (never prepend) so those Phase-0
|
|
1987
|
+
// numbers stay valid, then derive EVERY non-character directive's `Image N`
|
|
1988
|
+
// from this one final ordered list via a `url → index` map.
|
|
1989
|
+
//
|
|
1990
|
+
// The old code prepended `nonCharacterUrls` ahead of the Phase-0 list,
|
|
1991
|
+
// which shifted every character/extra `Image N` by `nonCharacterUrls.length`
|
|
1992
|
+
// while their directive text was already baked — the root cause of the
|
|
1993
|
+
// off-by-(non-char-count) desync. Appending is byte-identical to the old
|
|
1994
|
+
// prepend whenever a non-char URL already lives in `referenceImageUrls`
|
|
1995
|
+
// (dedup keeps its existing slot); it only differs for the genuinely-new
|
|
1996
|
+
// location/object URLs that the prepend mis-slotted.
|
|
1997
|
+
// -----------------------------------------------------------------------
|
|
1998
|
+
const nonCharacterUrls = nonCharacterRefs
|
|
1999
|
+
.map((r) => r.url)
|
|
2000
|
+
.filter((u): u is string => Boolean(u))
|
|
2001
|
+
const assembledUrls = [...referenceImageUrls, ...nonCharacterUrls]
|
|
2002
|
+
.filter((u, i, a) => a.indexOf(u) === i)
|
|
2003
|
+
const finalIndexByUrl = new Map<string, number>()
|
|
2004
|
+
assembledUrls.forEach((u, i) => {
|
|
2005
|
+
if (!finalIndexByUrl.has(u)) finalIndexByUrl.set(u, i + 1)
|
|
2006
|
+
})
|
|
2007
|
+
|
|
2008
|
+
// Non-character directives — `{image:N}` identities (renumbered to their
|
|
2009
|
+
// final slot) + the location/object canonical fallback — all numbered
|
|
2010
|
+
// against the unified `finalIndexByUrl`.
|
|
2011
|
+
const directives = buildNonCharacterDirectives(
|
|
2012
|
+
prompt,
|
|
2013
|
+
nonCharacterRefs,
|
|
2014
|
+
identityMeta,
|
|
2015
|
+
finalIndexByUrl,
|
|
2016
|
+
suppressedCanonicalLocationIds,
|
|
2017
|
+
)
|
|
2018
|
+
if (isHybrid) {
|
|
2019
|
+
// Hybrid reference format (images-only, flag-gated): TOKEN EXPANSION ONLY.
|
|
2020
|
+
// Every {image:N:label} token becomes "the <label> from reference image
|
|
2021
|
+
// <LETTER>". NO reference-lock snippet is auto-injected — authors prepend
|
|
2022
|
+
// their own lock (default-deny / likeness / compose / extract / ghost-
|
|
2023
|
+
// mannequin, per goal) in the prompt text, and it passes through here
|
|
2024
|
+
// untouched. A caller MAY still opt to prepend one via
|
|
2025
|
+
// `config.referenceLockSnippet`, but nothing is added by default.
|
|
2026
|
+
// Replaces the legacy "Use these references:/Compose:" wrap + numeric
|
|
2027
|
+
// token expansion. Objects stay legacy; CHARACTERS were already converged
|
|
2028
|
+
// in Phase 0 (role phrases + identity-lock + element directives), and
|
|
2029
|
+
// LOCATIONS converge here too — mentioned ones inline in Phase 0, the
|
|
2030
|
+
// unmentioned canonical ones as the trailing role phrases appended below.
|
|
2031
|
+
//
|
|
2032
|
+
// When Phase 0 converged a character OR location mention, the body is final
|
|
2033
|
+
// and intentionally lowercase ("the face from reference image A") — expand
|
|
2034
|
+
// any remaining {image:N} tokens but DON'T capitalize line-initials (which
|
|
2035
|
+
// would corrupt those phrases). Otherwise render the user scene as before
|
|
2036
|
+
// (capitalized line-initials); appended canonical phrases stay lowercase
|
|
2037
|
+
// because they're added AFTER this capitalizing pass.
|
|
2038
|
+
const scene = hybridBodyConverged
|
|
2039
|
+
? prompt
|
|
2040
|
+
.split("\n")
|
|
2041
|
+
.map((line) => expandImageRefTokensHybrid(line, nonCharacterRefs, finalIndexByUrl))
|
|
2042
|
+
.join("\n")
|
|
2043
|
+
: buildHybridScene(prompt, nonCharacterRefs, finalIndexByUrl)
|
|
2044
|
+
// URLs already expanded inline by an `{image:N}` token in this scene.
|
|
2045
|
+
// Derived EXACTLY as the legacy `buildNonCharacterDirectives` derives its
|
|
2046
|
+
// `coveredUrls` — via `collectIdentities` (labeled `{image:N:label}`
|
|
2047
|
+
// tokens present in the prompt, indexing `nonCharacterRefs[N-1]`) — so the
|
|
2048
|
+
// hybrid and legacy notions of "token-covered" share one source of truth
|
|
2049
|
+
// and never drift. Threaded into both canonical renders so an unmentioned
|
|
2050
|
+
// wired object / creature / location that is ALSO `{image:N}`-referenced
|
|
2051
|
+
// renders ONCE (inline), never also as a trailing canonical phrase.
|
|
2052
|
+
const tokenCoveredUrls = new Set<string>()
|
|
2053
|
+
for (const id of collectIdentities(prompt, nonCharacterRefs, identityMeta)) {
|
|
2054
|
+
const coveredUrl = nonCharacterRefs[id.imageIndex - 1]?.url
|
|
2055
|
+
if (coveredUrl) tokenCoveredUrls.add(coveredUrl)
|
|
2056
|
+
}
|
|
2057
|
+
// Unmentioned wired-location / -object / -creature canonical convergence
|
|
2058
|
+
// (Phase C): each → the trailing role phrase "the {role} from reference
|
|
2059
|
+
// image {LETTER}" + opt-in lock + wired elementInjection, numbered against
|
|
2060
|
+
// `finalIndexByUrl`. Mentioned locations were converged inline in Phase 0
|
|
2061
|
+
// and filtered out of `nonCharacterRefs`; objects/creatures have no mention
|
|
2062
|
+
// path, so their only inline route is an `{image:N}` token (guarded above).
|
|
2063
|
+
const locCanon = renderLocationCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, tokenCoveredUrls)
|
|
2064
|
+
const objCanon = renderObjectCreatureCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, tokenCoveredUrls)
|
|
2065
|
+
const canonLockLines = [...locCanon.lockLines, ...objCanon.lockLines]
|
|
2066
|
+
const canonLockBlock = canonLockLines.length > 0 ? `${canonLockLines.join("\n")}\n\n` : ""
|
|
2067
|
+
const canonTrailingLines = [
|
|
2068
|
+
...locCanon.phrases, ...objCanon.phrases,
|
|
2069
|
+
...locCanon.elementDirectives, ...objCanon.elementDirectives,
|
|
2070
|
+
]
|
|
2071
|
+
const canonTrailingBlock = canonTrailingLines.length > 0 ? `\n${canonTrailingLines.join("\n")}` : ""
|
|
2072
|
+
const composedScene = `${canonLockBlock}${scene}${canonTrailingBlock}`
|
|
2073
|
+
prompt = config.referenceLockSnippet
|
|
2074
|
+
? `${config.referenceLockSnippet}\n${composedScene}`
|
|
2075
|
+
: composedScene
|
|
2076
|
+
// The hybrid token rewrite can't be modeled by the segment marks → the
|
|
2077
|
+
// join-mismatch fallback in buildImagePromptSegments collapses to a single
|
|
2078
|
+
// segment (documented degradation, same as the Phase-0 splice branch).
|
|
2079
|
+
if (marks) marks.directivesPrefix = ""
|
|
2080
|
+
} else if (directives) {
|
|
2081
|
+
// Capture the prompt state across the splice so the segment builder can
|
|
2082
|
+
// isolate what the directive block contributed as a PREFIX. Only the
|
|
2083
|
+
// prepend branch yields a clean prefix; the Phase-0 consolidation branch
|
|
2084
|
+
// splices mid-string (no prefix) → marks.directivesPrefix stays "" and
|
|
2085
|
+
// the body collapses via the join fallback (documented degradation).
|
|
2086
|
+
const beforeDirectives = prompt
|
|
2087
|
+
if (prompt.startsWith("Use these characters:\n")) {
|
|
2088
|
+
// Consolidate into the existing Phase-0 directive block (the same splice
|
|
2089
|
+
// pattern Phase 0 uses for fallback / extras) instead of wrapping it in
|
|
2090
|
+
// a second "Use these references…/Compose them naturally" layer.
|
|
2091
|
+
const splitIdx = prompt.indexOf("\n\n")
|
|
2092
|
+
if (splitIdx !== -1) {
|
|
2093
|
+
prompt = prompt.slice(0, splitIdx) + "\n" + directives + prompt.slice(splitIdx)
|
|
2094
|
+
} else {
|
|
2095
|
+
prompt = `${prompt}\n${directives}`
|
|
2096
|
+
}
|
|
2097
|
+
} else {
|
|
2098
|
+
// "Use these references…" header + bulleted directives + the
|
|
2099
|
+
// "Compose them naturally…" prefix proved most reliable in user
|
|
2100
|
+
// testing. Numeric indices ("Image 1") match the user-typed
|
|
2101
|
+
// `@character:N` slug format so the literal prompt and the final
|
|
2102
|
+
// identity directive are visually linked.
|
|
2103
|
+
prompt = prompt
|
|
2104
|
+
? `Use these references for the output image:\n${directives}\n\nCompose them naturally into a single image: ${prompt}`
|
|
2105
|
+
: `Use these references for the output image:\n${directives}`
|
|
2106
|
+
// Prepend case: the new string ends with `beforeDirectives` (or is the
|
|
2107
|
+
// whole prefix when the body was empty). Everything before that tail is
|
|
2108
|
+
// the directive prefix.
|
|
2109
|
+
if (marks) {
|
|
2110
|
+
marks.directivesPrefix = prompt.slice(0, prompt.length - beforeDirectives.length)
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
const styleText = style?.trim()
|
|
2116
|
+
const styleSuffix = styleText ? `\nStyle: ${getStylePromptHint(styleText) || styleText}` : ""
|
|
2117
|
+
|
|
2118
|
+
const negPrompt = negativePrompt?.trim()
|
|
2119
|
+
let nativeNegativePrompt: string | undefined
|
|
2120
|
+
let avoidSuffix = ""
|
|
2121
|
+
if (negPrompt) {
|
|
2122
|
+
if (NATIVE_NEGATIVE_PROMPT_MODELS.has(provider)) {
|
|
2123
|
+
// Clamp native negatives to the provider's verified cap (e.g. ideogram /
|
|
2124
|
+
// qwen = 500) so an over-long negative can't trigger a provider reject.
|
|
2125
|
+
nativeNegativePrompt = negPrompt.slice(0, getMaxNegativePromptChars(provider))
|
|
2126
|
+
} else {
|
|
2127
|
+
avoidSuffix = `\nAvoid: ${negPrompt}`
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
if (marks) {
|
|
2131
|
+
marks.styleSuffix = styleSuffix
|
|
2132
|
+
marks.avoidSuffix = avoidSuffix
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// Cap the assembled prompt at the PROVIDER's max (default IMAGE_PROMPT_MAX =
|
|
2136
|
+
// 5000, what the image routes already accept) — never the old hardcoded
|
|
2137
|
+
// 2000, which silently severed the appended cinematography/picker hints +
|
|
2138
|
+
// the `Avoid:` negative whenever the body was long. The style/avoid suffixes
|
|
2139
|
+
// are RESERVED first so a long body can never drop them (the control text is
|
|
2140
|
+
// the most important to keep). Truncate the BODY, THEN append the suffixes.
|
|
2141
|
+
const maxLen = getMaxImagePromptChars(provider)
|
|
2142
|
+
const reserved = styleSuffix.length + avoidSuffix.length
|
|
2143
|
+
if (prompt.length + reserved > maxLen) {
|
|
2144
|
+
prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
|
|
2145
|
+
}
|
|
2146
|
+
// Body span = everything after the captured directive prefix, taken from the
|
|
2147
|
+
// possibly-truncated body so the segment join still reconstructs (empty in the
|
|
2148
|
+
// Phase-0 consolidation branch → collapses via the fallback, the documented
|
|
2149
|
+
// degradation).
|
|
2150
|
+
if (marks) marks.bodyBeforeSuffixes = prompt.slice(marks.directivesPrefix.length)
|
|
2151
|
+
prompt = prompt + styleSuffix + avoidSuffix
|
|
2152
|
+
// Safety clamp for a pathological native-less negative that alone overflows.
|
|
2153
|
+
if (prompt.length > maxLen) {
|
|
2154
|
+
prompt = prompt.slice(0, maxLen - 3) + "..."
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// Resolve `{image:N:label}` → "Image <finalSlot> (label)". The token's N
|
|
2158
|
+
// indexes `nonCharacterRefs`; we rewrite it to the ref's FINAL slot so the
|
|
2159
|
+
// body text agrees with the directive numbering above. (The hybrid format
|
|
2160
|
+
// already expanded tokens to lettered role phrases in buildHybridScene.)
|
|
2161
|
+
if (!isHybrid) {
|
|
2162
|
+
prompt = expandImageRefTokensForRefs(prompt, nonCharacterRefs, finalIndexByUrl)
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
const supportsRefs = MODELS_WITH_REFERENCE_IMAGE_SUPPORT.has(provider)
|
|
2166
|
+
|
|
2167
|
+
// Apply user-defined `referenceOrder` to the final URL list, with a
|
|
2168
|
+
// matching renumber of every `Image N` token in the prompt. Skipped when
|
|
2169
|
+
// the model doesn't support refs (refs will be dropped anyway) or when no
|
|
2170
|
+
// order was supplied (no-op contract).
|
|
2171
|
+
let finalUrls = assembledUrls
|
|
2172
|
+
let finalPrompt = prompt
|
|
2173
|
+
// v1: the hybrid format skips referenceOrder renumbering (its tokens are
|
|
2174
|
+
// already expanded to fixed letters). Reorder support is a follow-up.
|
|
2175
|
+
if (!isHybrid && supportsRefs && referenceOrder.length > 0 && assembledUrls.length > 1) {
|
|
2176
|
+
const reordered = applyReferenceOrder(
|
|
2177
|
+
assembledUrls,
|
|
2178
|
+
prompt,
|
|
2179
|
+
connectedReferences,
|
|
2180
|
+
referenceOrder,
|
|
2181
|
+
sourceNodeIdById,
|
|
2182
|
+
)
|
|
2183
|
+
finalUrls = reordered.urls
|
|
2184
|
+
finalPrompt = reordered.prompt
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
const refsToSend = supportsRefs && finalUrls.length > 0 ? finalUrls : undefined
|
|
2188
|
+
|
|
2189
|
+
return { prompt: finalPrompt, nativeNegativePrompt, referenceImageUrls: refsToSend }
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// -------------------------------------------------------------------------
|
|
2193
|
+
// Legacy path — kept for callers that haven't migrated to connectedReferences.
|
|
2194
|
+
// -------------------------------------------------------------------------
|
|
2195
|
+
|
|
2196
|
+
// Build character description lines
|
|
2197
|
+
const charDescs = characterDefs
|
|
2198
|
+
.filter((c) => c.type === "description" && c.description)
|
|
2199
|
+
.map((c) => {
|
|
2200
|
+
let templateKey: string
|
|
2201
|
+
switch (c.category) {
|
|
2202
|
+
case "face": templateKey = "face-description"; break
|
|
2203
|
+
case "location": templateKey = "location-description"; break
|
|
2204
|
+
case "object": templateKey = "object-description"; break
|
|
2205
|
+
default: templateKey = "character-description"; break
|
|
2206
|
+
}
|
|
2207
|
+
const template = resolveTemplate(templateKey, userTemplates, flowTemplates)
|
|
2208
|
+
return applyTemplate(template, {
|
|
2209
|
+
name: c.name,
|
|
2210
|
+
description: c.description || "",
|
|
2211
|
+
})
|
|
2212
|
+
})
|
|
2213
|
+
|
|
2214
|
+
// Assemble prompt
|
|
2215
|
+
let prompt = config.prompt
|
|
2216
|
+
if (charDescs.length > 0) {
|
|
2217
|
+
const wrapperTemplate = resolveTemplate("generate-image-wrapper", userTemplates, flowTemplates)
|
|
2218
|
+
prompt = applyTemplate(wrapperTemplate, {
|
|
2219
|
+
userPrompt: prompt,
|
|
2220
|
+
assetDescriptions: charDescs.join(" "),
|
|
2221
|
+
})
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// Append style — if the inline `style` is a known STYLES catalog id, inject
|
|
2225
|
+
// the richer promptHint; otherwise fall back to the raw text (covers custom
|
|
2226
|
+
// free-text styles that don't match a preset).
|
|
2227
|
+
const styleText = style?.trim()
|
|
2228
|
+
const styleSuffix = styleText ? `\nStyle: ${getStylePromptHint(styleText) || styleText}` : ""
|
|
2229
|
+
|
|
2230
|
+
// Handle negative prompt: native support vs prompt-appended
|
|
2231
|
+
const negPrompt = negativePrompt?.trim()
|
|
2232
|
+
let nativeNegativePrompt: string | undefined
|
|
2233
|
+
let avoidSuffix = ""
|
|
2234
|
+
if (negPrompt) {
|
|
2235
|
+
if (NATIVE_NEGATIVE_PROMPT_MODELS.has(provider)) {
|
|
2236
|
+
nativeNegativePrompt = negPrompt.slice(0, getMaxNegativePromptChars(provider))
|
|
2237
|
+
} else {
|
|
2238
|
+
avoidSuffix = `\nAvoid: ${negPrompt}`
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
if (marks) {
|
|
2242
|
+
marks.styleSuffix = styleSuffix
|
|
2243
|
+
marks.avoidSuffix = avoidSuffix
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// Cap at the provider max (default IMAGE_PROMPT_MAX = 5000), reserving the
|
|
2247
|
+
// style/avoid suffixes so a long body never severs the appended control text
|
|
2248
|
+
// (was a hardcoded 2000 tail-cut that dropped the negative). Truncate the
|
|
2249
|
+
// BODY, THEN append the suffixes.
|
|
2250
|
+
const maxLen = getMaxImagePromptChars(provider)
|
|
2251
|
+
const reserved = styleSuffix.length + avoidSuffix.length
|
|
2252
|
+
if (prompt.length + reserved > maxLen) {
|
|
2253
|
+
prompt = prompt.slice(0, Math.max(0, maxLen - reserved - 3)) + "..."
|
|
2254
|
+
}
|
|
2255
|
+
// Legacy path has no directive prefix; the body is the char-desc-wrapped
|
|
2256
|
+
// (possibly-truncated) prompt right before the style/avoid suffixes.
|
|
2257
|
+
if (marks) marks.bodyBeforeSuffixes = prompt
|
|
2258
|
+
prompt = prompt + styleSuffix + avoidSuffix
|
|
2259
|
+
// Safety clamp for a pathological native-less negative that alone overflows.
|
|
2260
|
+
if (prompt.length > maxLen) {
|
|
2261
|
+
prompt = prompt.slice(0, maxLen - 3) + "..."
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// Merge reference images: direct refs first, then ancestor fallback
|
|
2265
|
+
const allRefs = referenceImageUrls.length > 0 ? referenceImageUrls : ancestorRefs
|
|
2266
|
+
const supportsRefs = MODELS_WITH_REFERENCE_IMAGE_SUPPORT.has(provider)
|
|
2267
|
+
const refsToSend = supportsRefs && allRefs.length > 0 ? allRefs : undefined
|
|
2268
|
+
|
|
2269
|
+
// Expand {image:N} position references in prompt (legacy: → "[reference image N]")
|
|
2270
|
+
prompt = expandImagePositionRefs(prompt, allRefs.length)
|
|
2271
|
+
|
|
2272
|
+
return { prompt, nativeNegativePrompt, referenceImageUrls: refsToSend }
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/**
|
|
2276
|
+
* `buildImagePrompt` plus an origin-tagged decomposition of the assembled
|
|
2277
|
+
* `prompt`. The string output (and all other fields) is byte-identical to
|
|
2278
|
+
* `buildImagePrompt` — the segments are derived from assembly marks recorded
|
|
2279
|
+
* during the same pass.
|
|
2280
|
+
*
|
|
2281
|
+
* ABSOLUTE INVARIANT (tested): `segments.map(s => s.text).join("") === prompt`.
|
|
2282
|
+
* Anything the marks can't model — `{image:N}` token expansion, `referenceOrder`
|
|
2283
|
+
* renumbering, mid-string Phase-0 directive splicing, truncation — breaks the
|
|
2284
|
+
* join, and we collapse to a single `user` segment rather than ship a wrong
|
|
2285
|
+
* decomposition. Callers pass `bodySegments` (origins user/variable/picker/…)
|
|
2286
|
+
* for the body text; they survive only when the assembly didn't rewrite the
|
|
2287
|
+
* body string.
|
|
2288
|
+
*/
|
|
2289
|
+
export function buildImagePromptSegments(
|
|
2290
|
+
config: BuildImagePromptConfig,
|
|
2291
|
+
bodySegments?: readonly PromptSegment[],
|
|
2292
|
+
): BuildImagePromptSegmentsResult {
|
|
2293
|
+
const marks: AssemblyMarks = {
|
|
2294
|
+
directivesPrefix: "",
|
|
2295
|
+
bodyBeforeSuffixes: "",
|
|
2296
|
+
styleSuffix: "",
|
|
2297
|
+
avoidSuffix: "",
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
const result = buildImagePromptInternal(config, marks)
|
|
2301
|
+
|
|
2302
|
+
let segments: PromptSegment[] = [
|
|
2303
|
+
...(marks.directivesPrefix ? [{ text: marks.directivesPrefix, origin: "mention" as const }] : []),
|
|
2304
|
+
...reconcileBodySegments(marks.bodyBeforeSuffixes, bodySegments),
|
|
2305
|
+
...(marks.styleSuffix ? [{ text: marks.styleSuffix, origin: "style" as const }] : []),
|
|
2306
|
+
...(marks.avoidSuffix ? [{ text: marks.avoidSuffix, origin: "negative" as const }] : []),
|
|
2307
|
+
]
|
|
2308
|
+
// Absolute invariant: join === prompt. Any assembly step we didn't model
|
|
2309
|
+
// (truncation, token expansion, reorder renumbering, Phase-0 mid-string
|
|
2310
|
+
// splice) breaks the join — detect against the FINAL returned prompt and
|
|
2311
|
+
// fall back rather than ship a wrong decomposition.
|
|
2312
|
+
if (segments.map((s) => s.text).join("") !== result.prompt) {
|
|
2313
|
+
segments = result.prompt ? [{ text: result.prompt, origin: "user" }] : []
|
|
2314
|
+
}
|
|
2315
|
+
return { ...result, segments }
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
// ---------------------------------------------------------------------------
|
|
2319
|
+
// Identity helpers (new system)
|
|
2320
|
+
// ---------------------------------------------------------------------------
|
|
2321
|
+
|
|
2322
|
+
/** Sources whose default fidelity is "strict" — they carry strong identity. */
|
|
2323
|
+
const STRICT_DEFAULT_SOURCES: ReadonlySet<ReferenceSource> = new Set([
|
|
2324
|
+
"wired-character",
|
|
2325
|
+
"wired-face",
|
|
2326
|
+
"wired-object",
|
|
2327
|
+
"wired-creature",
|
|
2328
|
+
"wired-location",
|
|
2329
|
+
])
|
|
2330
|
+
|
|
2331
|
+
/** Matches `{image:N}` and `{image:N:label}` tokens.
|
|
2332
|
+
* Group 1 = position, group 2 = optional label. */
|
|
2333
|
+
// Label allows spaces (multi-word labels like "clothes and shoes") but never a
|
|
2334
|
+
// newline or `}` so the match can't run away across lines / token boundaries.
|
|
2335
|
+
const IMAGE_TOKEN_PATTERN = /\{image:(\d+)(?::([^}\n]+))?\}/gi
|
|
2336
|
+
|
|
2337
|
+
interface ResolvedIdentity {
|
|
2338
|
+
imageIndex: number
|
|
2339
|
+
label: string // empty string = bare positional ref (no role)
|
|
2340
|
+
fidelity: IdentityFidelity
|
|
2341
|
+
customText?: string
|
|
2342
|
+
description?: string // from upstream connected reference
|
|
2343
|
+
/** `source` from the upstream ConnectedReference — used to opt the directive
|
|
2344
|
+
* builder into source-aware behavior (e.g. appending location canonical
|
|
2345
|
+
* description to wired-location bullets). Mirrors the character pattern
|
|
2346
|
+
* where `characterCanonicalDescription` is used by the Phase 0 mention
|
|
2347
|
+
* resolver, but for locations the consumer is the directive builder. */
|
|
2348
|
+
source?: ReferenceSource
|
|
2349
|
+
/** Location's canonical description, propagated from the wired-location
|
|
2350
|
+
* ConnectedReference. The directive builder appends this to the bullet
|
|
2351
|
+
* when source === "wired-location" AND the location's slug is NOT in
|
|
2352
|
+
* `suppressedCanonicalLocationIds`. */
|
|
2353
|
+
locationCanonicalDescription?: string | null
|
|
2354
|
+
/** Slug for the location source, used by `suppressedCanonicalLocationIds`
|
|
2355
|
+
* filtering. */
|
|
2356
|
+
locationSlug?: string
|
|
2357
|
+
/** Mirrors ConnectedReference.locationReferencePhotoKind — propagated so
|
|
2358
|
+
* `buildIdentityDirective` can annotate the subject line. */
|
|
2359
|
+
locationReferencePhotoKind?: string
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
function defaultFidelityForSource(source: ReferenceSource | undefined): IdentityFidelity {
|
|
2363
|
+
if (!source) return "balanced"
|
|
2364
|
+
return STRICT_DEFAULT_SOURCES.has(source) ? "strict" : "balanced"
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
/**
|
|
2368
|
+
* Return the unique set of identities to emit directives for: only the
|
|
2369
|
+
* `{image:N:label}` mentions actually present in the prompt.
|
|
2370
|
+
*
|
|
2371
|
+
* Connected references that the user hasn't mentioned still get sent to the
|
|
2372
|
+
* provider (via `referenceImageUrls`), but they get no auto-generated
|
|
2373
|
+
* directive — the user controls what's in the prompt by mentioning.
|
|
2374
|
+
*/
|
|
2375
|
+
function collectIdentities(
|
|
2376
|
+
prompt: string,
|
|
2377
|
+
refs: readonly ConnectedReference[],
|
|
2378
|
+
meta: readonly IdentityMeta[],
|
|
2379
|
+
): ResolvedIdentity[] {
|
|
2380
|
+
const seen = new Set<string>()
|
|
2381
|
+
const mentions: Array<{ imageIndex: number; label: string }> = []
|
|
2382
|
+
for (const m of prompt.matchAll(IMAGE_TOKEN_PATTERN)) {
|
|
2383
|
+
const n = parseInt(m[1], 10)
|
|
2384
|
+
if (n < 1 || n > refs.length) continue
|
|
2385
|
+
const label = (m[2] ?? "").trim()
|
|
2386
|
+
if (!label) continue // bare {image:N} → no identity directive, just position
|
|
2387
|
+
const key = `${n}:${label}`
|
|
2388
|
+
if (seen.has(key)) continue
|
|
2389
|
+
seen.add(key)
|
|
2390
|
+
mentions.push({ imageIndex: n, label })
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
return mentions.map(({ imageIndex, label }) => {
|
|
2394
|
+
const ref = refs[imageIndex - 1]
|
|
2395
|
+
const m = meta.find((x) => x.imageIndex === imageIndex && x.label === label)
|
|
2396
|
+
return {
|
|
2397
|
+
imageIndex,
|
|
2398
|
+
label,
|
|
2399
|
+
fidelity: m?.fidelity ?? defaultFidelityForSource(ref?.source),
|
|
2400
|
+
customText: m?.customText?.trim() || undefined,
|
|
2401
|
+
description: ref?.description,
|
|
2402
|
+
source: ref?.source,
|
|
2403
|
+
locationCanonicalDescription: ref?.locationCanonicalDescription,
|
|
2404
|
+
locationSlug: ref?.locationSlug,
|
|
2405
|
+
locationReferencePhotoKind: ref?.locationReferencePhotoKind,
|
|
2406
|
+
}
|
|
2407
|
+
})
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
/** Labels that mean "use as scene/setting" — verb shifts away from "match". */
|
|
2411
|
+
const BACKGROUND_LABELS: ReadonlySet<string> = new Set([
|
|
2412
|
+
"background", "setting", "scene", "environment", "location",
|
|
2413
|
+
])
|
|
2414
|
+
/** Labels that mean "apply this look/material" rather than "include this thing". */
|
|
2415
|
+
const TEXTURE_LABELS: ReadonlySet<string> = new Set([
|
|
2416
|
+
"texture", "style", "pattern", "look", "material",
|
|
2417
|
+
])
|
|
2418
|
+
|
|
2419
|
+
/**
|
|
2420
|
+
* Subject form for directive bullets: parenthetical label after the image
|
|
2421
|
+
* index so the model sees both the position binding and the role descriptor
|
|
2422
|
+
* in one tight phrase. Numeric indices ("Image 1") match the typed token
|
|
2423
|
+
* (e.g. `@kira:1:smile`) so users can trace the slug through to the final
|
|
2424
|
+
* assembled directive.
|
|
2425
|
+
*
|
|
2426
|
+
* "dragon" + 1 → "Image 1 (dragon)"
|
|
2427
|
+
* "Danny" + 2 → "Image 2 (Danny)"
|
|
2428
|
+
* "dragon" + 1 + desc "red scales" → "Image 1 (dragon — red scales)"
|
|
2429
|
+
* no label + 3 → "Image 3"
|
|
2430
|
+
*/
|
|
2431
|
+
function formatDirectiveSubject(label: string, imageIndex: number, description?: string): string {
|
|
2432
|
+
if (!label && !description) return `Image ${imageIndex}`
|
|
2433
|
+
const inner = label && description
|
|
2434
|
+
? `${label} — ${description}`
|
|
2435
|
+
: (label || description)!
|
|
2436
|
+
return `Image ${imageIndex} (${inner})`
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
/** Labels that mean "a person / character" — strengthen the directive so the
|
|
2440
|
+
* model holds the face + body + distinctive features. Folds the
|
|
2441
|
+
* identity-preservation language (previously appended as a trailing
|
|
2442
|
+
* global clause via `collectIdentityLockClause`) directly into the per-image
|
|
2443
|
+
* bullet so the model sees the identifier and the rule together. */
|
|
2444
|
+
const PERSON_LABELS: ReadonlySet<string> = new Set([
|
|
2445
|
+
"person", "character", "face", "subject", "people",
|
|
2446
|
+
])
|
|
2447
|
+
|
|
2448
|
+
/** Labels that mean "a creature / animal subject" — the animal analog of
|
|
2449
|
+
* PERSON_LABELS. A bound creature is a LIVING subject with its own identity
|
|
2450
|
+
* (a specific cat, dragon, beast), so the directive locks anatomy, markings,
|
|
2451
|
+
* coloration, and distinctive features instead of the person face/body
|
|
2452
|
+
* phrasing — and instead of the generic prop "match exactly" verb a
|
|
2453
|
+
* `wired-object` gets. */
|
|
2454
|
+
const CREATURE_LABELS: ReadonlySet<string> = new Set([
|
|
2455
|
+
"creature", "animal", "pet", "beast", "monster",
|
|
2456
|
+
])
|
|
2457
|
+
|
|
2458
|
+
function buildIdentityDirective(
|
|
2459
|
+
id: ResolvedIdentity,
|
|
2460
|
+
suppressedCanonicalLocationIds?: readonly string[],
|
|
2461
|
+
): string {
|
|
2462
|
+
if (id.fidelity === "custom" && id.customText) {
|
|
2463
|
+
return `- ${id.customText}`
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
// Location canonical-description injection (Phase 2 #1). Mirrors the
|
|
2467
|
+
// character canonical-description pattern (see `characterCanonicalDescription`
|
|
2468
|
+
// in the Phase 0 mention resolver above). When a wired-location ref has
|
|
2469
|
+
// canonical text AND the user hasn't suppressed it via the × button on the
|
|
2470
|
+
// Injected References list, the canonical description gets folded into the
|
|
2471
|
+
// directive subject so the model sees "Image N (location — <canonical
|
|
2472
|
+
// description>)" without the user typing it. Per-ref description (from the
|
|
2473
|
+
// user-typed Description field on the location node) still wins when present.
|
|
2474
|
+
let effectiveDescription = id.description
|
|
2475
|
+
if (
|
|
2476
|
+
!effectiveDescription
|
|
2477
|
+
&& id.source === "wired-location"
|
|
2478
|
+
&& id.locationCanonicalDescription
|
|
2479
|
+
&& !(id.locationSlug && suppressedCanonicalLocationIds?.includes(id.locationSlug))
|
|
2480
|
+
) {
|
|
2481
|
+
effectiveDescription = id.locationCanonicalDescription.trim() || undefined
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// Phase 2 #3: kind-tagged reference-photo annotation. When the ref carries
|
|
2485
|
+
// a `locationReferencePhotoKind`, fold its human-friendly label into the
|
|
2486
|
+
// subject's parenthetical so the model sees the photo's role inline with
|
|
2487
|
+
// the location name (e.g. "Image 1 (Old Library — wide-angle reference)").
|
|
2488
|
+
// The annotation rides on the LABEL (not the description) so it survives
|
|
2489
|
+
// the description-overrides-canonical precedence above.
|
|
2490
|
+
let effectiveLabel = id.label
|
|
2491
|
+
if (id.locationReferencePhotoKind && effectiveLabel) {
|
|
2492
|
+
effectiveLabel = `${effectiveLabel} — ${locationReferencePhotoKindLabel(id.locationReferencePhotoKind as LocationReferencePhotoKind)}`
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
const subject = formatDirectiveSubject(effectiveLabel, id.imageIndex, effectiveDescription)
|
|
2496
|
+
const lower = id.label.toLowerCase()
|
|
2497
|
+
|
|
2498
|
+
// Role-aware verbs — these read more naturally than "match exactly" for
|
|
2499
|
+
// background scenes or texture/style references.
|
|
2500
|
+
if (BACKGROUND_LABELS.has(lower)) {
|
|
2501
|
+
return `- ${subject} — use as the background/setting.`
|
|
2502
|
+
}
|
|
2503
|
+
if (TEXTURE_LABELS.has(lower)) {
|
|
2504
|
+
return `- ${subject} — apply this ${id.label}.`
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// Person/character labels: strengthen the directive (regardless of fidelity)
|
|
2508
|
+
// so the global trailing identity-lock clause becomes redundant.
|
|
2509
|
+
// "loose" still gets the inspiration form so users can opt out.
|
|
2510
|
+
if (PERSON_LABELS.has(lower) && id.fidelity !== "loose") {
|
|
2511
|
+
return `- ${subject} — match exactly. Maintain perfect likeness (face, body proportions, distinctive features).`
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// Creature/animal labels: the same identity-lock strength as a person, with
|
|
2515
|
+
// animal-subject phrasing — anatomy/markings/coloration are what make THIS
|
|
2516
|
+
// cat this cat. "loose" still opts out to the inspiration form below.
|
|
2517
|
+
if (CREATURE_LABELS.has(lower) && id.fidelity !== "loose") {
|
|
2518
|
+
return `- ${subject} — this is a creature/animal subject: match it exactly. Maintain perfect likeness (anatomy, markings, coloration, distinctive features).`
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
switch (id.fidelity) {
|
|
2522
|
+
case "strict":
|
|
2523
|
+
return `- ${subject} — match exactly. Maintain perfect likeness.`
|
|
2524
|
+
case "loose":
|
|
2525
|
+
return `- ${subject} — use loosely as inspiration.`
|
|
2526
|
+
case "custom": // custom without text falls through to balanced
|
|
2527
|
+
case "balanced":
|
|
2528
|
+
default:
|
|
2529
|
+
return `- ${subject} — match exactly.`
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/**
|
|
2534
|
+
* Build the non-character directive block for `buildImagePrompt`'s
|
|
2535
|
+
* connectedReferences path:
|
|
2536
|
+
* 1. `{image:N:label}` identity directives (legacy positional mentions), in
|
|
2537
|
+
* prompt-mention order, renumbered from their `nonCharacterRefs` index to
|
|
2538
|
+
* the ref's FINAL slot.
|
|
2539
|
+
* 2. Canonical-style fallback directives for `wired-location` / `wired-object`
|
|
2540
|
+
* / `wired-creature` refs that are present but neither `{image:N}`-referenced
|
|
2541
|
+
* nor @-mentioned (@-mentioned locations were already filtered out of
|
|
2542
|
+
* connectedReferences in Phase 0). Mirrors the character canonical fallback
|
|
2543
|
+
* so a wired setting / object / creature gets a directive with zero typing
|
|
2544
|
+
* required. Scoped to those three sources — opaque `manual` / `wired-image`
|
|
2545
|
+
* uploads carry no metadata to describe and keep their (intentional)
|
|
2546
|
+
* directive-free behavior.
|
|
2547
|
+
*
|
|
2548
|
+
* Every directive's `Image N` is numbered against `finalIndexByUrl` (the single
|
|
2549
|
+
* source of truth for the assembled URL order), so the index always matches the
|
|
2550
|
+
* URL slot. `coveredUrls` prevents a URL emitted via a token from being
|
|
2551
|
+
* re-emitted by the fallback AND dedupes a location/object wired twice.
|
|
2552
|
+
*/
|
|
2553
|
+
function buildNonCharacterDirectives(
|
|
2554
|
+
prompt: string,
|
|
2555
|
+
nonCharacterRefs: readonly ConnectedReference[],
|
|
2556
|
+
identityMeta: readonly IdentityMeta[],
|
|
2557
|
+
finalIndexByUrl: ReadonlyMap<string, number>,
|
|
2558
|
+
suppressedCanonicalLocationIds: readonly string[],
|
|
2559
|
+
): string {
|
|
2560
|
+
const lines: string[] = []
|
|
2561
|
+
const coveredUrls = new Set<string>()
|
|
2562
|
+
|
|
2563
|
+
for (const id of collectIdentities(prompt, nonCharacterRefs, identityMeta)) {
|
|
2564
|
+
const ref = nonCharacterRefs[id.imageIndex - 1]
|
|
2565
|
+
const finalIdx = ref?.url ? finalIndexByUrl.get(ref.url) : undefined
|
|
2566
|
+
const line = buildIdentityDirective(
|
|
2567
|
+
finalIdx ? { ...id, imageIndex: finalIdx } : id,
|
|
2568
|
+
suppressedCanonicalLocationIds,
|
|
2569
|
+
)
|
|
2570
|
+
if (line.length > 0) lines.push(line)
|
|
2571
|
+
if (ref?.url) coveredUrls.add(ref.url)
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
for (const ref of nonCharacterRefs) {
|
|
2575
|
+
if (ref.source !== "wired-location" && ref.source !== "wired-object" && ref.source !== "wired-creature") continue
|
|
2576
|
+
if (!ref.url || coveredUrls.has(ref.url)) continue
|
|
2577
|
+
const finalIdx = finalIndexByUrl.get(ref.url)
|
|
2578
|
+
if (!finalIdx) continue
|
|
2579
|
+
coveredUrls.add(ref.url)
|
|
2580
|
+
const line = buildIdentityDirective(
|
|
2581
|
+
{
|
|
2582
|
+
imageIndex: finalIdx,
|
|
2583
|
+
// A background label routes wired-location through the
|
|
2584
|
+
// "use as the background/setting" verb (and folds
|
|
2585
|
+
// locationCanonicalDescription); wired-creature routes through the
|
|
2586
|
+
// creature/animal-subject identity lock (CREATURE_LABELS);
|
|
2587
|
+
// wired-object falls through to the strict "match exactly" verb.
|
|
2588
|
+
label: ref.source === "wired-location"
|
|
2589
|
+
? "location"
|
|
2590
|
+
: ref.source === "wired-creature"
|
|
2591
|
+
? "creature"
|
|
2592
|
+
: "object",
|
|
2593
|
+
fidelity: defaultFidelityForSource(ref.source),
|
|
2594
|
+
description: ref.description?.trim() || undefined,
|
|
2595
|
+
source: ref.source,
|
|
2596
|
+
locationCanonicalDescription: ref.locationCanonicalDescription,
|
|
2597
|
+
locationSlug: ref.locationSlug,
|
|
2598
|
+
locationReferencePhotoKind: ref.locationReferencePhotoKind,
|
|
2599
|
+
},
|
|
2600
|
+
suppressedCanonicalLocationIds,
|
|
2601
|
+
)
|
|
2602
|
+
if (line.length > 0) lines.push(line)
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
return lines.join("\n")
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
/**
|
|
2609
|
+
* Replace `{image:N:label}` and `{image:N}` tokens with natural-language
|
|
2610
|
+
* phrases bound to a numbered image (Image 1 / 2 / 3 …).
|
|
2611
|
+
*
|
|
2612
|
+
* - `{image:N:label}` → `Image {N} ({label})`
|
|
2613
|
+
* - `{image:N}` → `Image {N}` (no role specified)
|
|
2614
|
+
*
|
|
2615
|
+
* Same parenthetical form as the directive subject so the model sees a
|
|
2616
|
+
* consistent identifier in both the bulleted list and the scene description.
|
|
2617
|
+
* Numeric indices match the user-typed slug format (`@kira:1:smile`).
|
|
2618
|
+
*
|
|
2619
|
+
* Out-of-range indices are left untouched so they're visible in the output.
|
|
2620
|
+
*/
|
|
2621
|
+
export function expandImageRefTokens(prompt: string, imageCount: number): string {
|
|
2622
|
+
return prompt.replace(IMAGE_TOKEN_PATTERN, (match, num, label) => {
|
|
2623
|
+
const n = parseInt(num, 10)
|
|
2624
|
+
if (n < 1 || n > imageCount) return match
|
|
2625
|
+
return label ? `Image ${n} (${label})` : `Image ${n}`
|
|
2626
|
+
})
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
/**
|
|
2630
|
+
* Like `expandImageRefTokens`, but remaps each `{image:N}` token from its index
|
|
2631
|
+
* in `refs` (the non-character ref list the token was authored against) to the
|
|
2632
|
+
* ref's FINAL 1-based slot in the assembled URL list. Used by
|
|
2633
|
+
* `buildImagePrompt`'s connectedReferences path so body-text `Image N` markers
|
|
2634
|
+
* agree with the directive numbering after non-character URLs are appended.
|
|
2635
|
+
*
|
|
2636
|
+
* When the final slot equals the token index (no Phase-0 prefix shifting the
|
|
2637
|
+
* positions), the output is byte-identical to `expandImageRefTokens`.
|
|
2638
|
+
* Out-of-range tokens (or refs without a URL) are left untouched, matching the
|
|
2639
|
+
* "visible so the author can fix it" contract.
|
|
2640
|
+
*/
|
|
2641
|
+
function expandImageRefTokensForRefs(
|
|
2642
|
+
prompt: string,
|
|
2643
|
+
refs: readonly ConnectedReference[],
|
|
2644
|
+
finalIndexByUrl: ReadonlyMap<string, number>,
|
|
2645
|
+
): string {
|
|
2646
|
+
return prompt.replace(IMAGE_TOKEN_PATTERN, (match, num, label) => {
|
|
2647
|
+
const n = parseInt(num, 10)
|
|
2648
|
+
const ref = refs[n - 1]
|
|
2649
|
+
const finalIdx = ref?.url ? finalIndexByUrl.get(ref.url) : undefined
|
|
2650
|
+
if (!finalIdx) return match
|
|
2651
|
+
return label ? `Image ${finalIdx} (${label})` : `Image ${finalIdx}`
|
|
2652
|
+
})
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
// ---------------------------------------------------------------------------
|
|
2656
|
+
// Hybrid reference format (images-only, flag-gated via
|
|
2657
|
+
// `BuildImagePromptConfig.referenceFormat === "hybrid"`). TOKEN EXPANSION ONLY:
|
|
2658
|
+
// every {image:N:label} token → "the <label> from reference image <LETTER>"
|
|
2659
|
+
// (letters map the FINAL 1-based slot, 1→A). NO reference-lock snippet is
|
|
2660
|
+
// auto-injected — authors prepend their own lock in the prompt text (it passes
|
|
2661
|
+
// through untouched); a caller may optionally prepend one via
|
|
2662
|
+
// `config.referenceLockSnippet`. Scope is the `{image:N:label}` non-character
|
|
2663
|
+
// path — characters/objects/locations still use the legacy wrap.
|
|
2664
|
+
// ---------------------------------------------------------------------------
|
|
2665
|
+
|
|
2666
|
+
/** Map a 1-based reference slot to a letter (1→A, 2→B …). Slots beyond 26 fall
|
|
2667
|
+
* back to the numeric slot so we never emit a non-letter control character. */
|
|
2668
|
+
function slotToLetter(slot: number): string {
|
|
2669
|
+
return slot >= 1 && slot <= 26 ? String.fromCharCode(64 + slot) : String(slot)
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
/** Hybrid inline phrase for a `{image:N:label}` token bound to a lettered slot.
|
|
2673
|
+
* Uniform: "the <label> from reference image <letter>" — the label is used
|
|
2674
|
+
* verbatim (the global identity directive handles subject preservation, so no
|
|
2675
|
+
* per-role branching is needed). A label-less token → "reference image <letter>". */
|
|
2676
|
+
function hybridRolePhrase(label: string, letter: string): string {
|
|
2677
|
+
if (!label) return `reference image ${letter}`
|
|
2678
|
+
return `the ${label} from reference image ${letter}`
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/** Like `expandImageRefTokensForRefs`, but emits the hybrid lettered phrase
|
|
2682
|
+
* ("the subject from reference image A") instead of the legacy "Image N
|
|
2683
|
+
* (label)". Out-of-range tokens / URL-less refs are left untouched. */
|
|
2684
|
+
function expandImageRefTokensHybrid(
|
|
2685
|
+
prompt: string,
|
|
2686
|
+
refs: readonly ConnectedReference[],
|
|
2687
|
+
finalIndexByUrl: ReadonlyMap<string, number>,
|
|
2688
|
+
): string {
|
|
2689
|
+
return prompt.replace(IMAGE_TOKEN_PATTERN, (match, num, label) => {
|
|
2690
|
+
const n = parseInt(num, 10)
|
|
2691
|
+
const ref = refs[n - 1]
|
|
2692
|
+
const finalIdx = ref?.url ? finalIndexByUrl.get(ref.url) : undefined
|
|
2693
|
+
if (!finalIdx) return match
|
|
2694
|
+
return hybridRolePhrase((label ?? "").trim(), slotToLetter(finalIdx))
|
|
2695
|
+
})
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
/** Capitalize the first alphabetic character of a string (line-initial). */
|
|
2699
|
+
function capitalizeLineInitial(line: string): string {
|
|
2700
|
+
return line.replace(/[a-zA-Z]/, (c) => c.toUpperCase())
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
/** Render the user prompt as the hybrid scene: each `{image:N:label}` token
|
|
2704
|
+
* expanded to its uniform lettered phrase, each line's first letter
|
|
2705
|
+
* capitalized. No per-role special-casing — the label drives the phrase. */
|
|
2706
|
+
function buildHybridScene(
|
|
2707
|
+
prompt: string,
|
|
2708
|
+
refs: readonly ConnectedReference[],
|
|
2709
|
+
finalIndexByUrl: ReadonlyMap<string, number>,
|
|
2710
|
+
): string {
|
|
2711
|
+
return prompt
|
|
2712
|
+
.split("\n")
|
|
2713
|
+
.map((line) => capitalizeLineInitial(expandImageRefTokensHybrid(line, refs, finalIndexByUrl)))
|
|
2714
|
+
.join("\n")
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
/** Build the combined per-identity directive intro for previews.
|
|
2718
|
+
* Each directive is emitted on its own line so the model (and the human
|
|
2719
|
+
* reading the FinalPromptPreview) can see the structure clearly. */
|
|
2720
|
+
export function buildIdentityDirectives(
|
|
2721
|
+
prompt: string,
|
|
2722
|
+
refs: readonly ConnectedReference[],
|
|
2723
|
+
meta: readonly IdentityMeta[] = [],
|
|
2724
|
+
suppressedCanonicalLocationIds?: readonly string[],
|
|
2725
|
+
): string {
|
|
2726
|
+
return collectIdentities(prompt, refs, meta)
|
|
2727
|
+
.map((id) => buildIdentityDirective(id, suppressedCanonicalLocationIds))
|
|
2728
|
+
.filter((s) => s.length > 0)
|
|
2729
|
+
.join("\n")
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
// ---------------------------------------------------------------------------
|
|
2733
|
+
// Backward-compat shims — preview/test helpers used elsewhere still reference
|
|
2734
|
+
// the old names. Keep thin wrappers so we don't break callers in this round.
|
|
2735
|
+
// ---------------------------------------------------------------------------
|
|
2736
|
+
|
|
2737
|
+
/** @deprecated Use `expandImageRefTokens` (label-aware). */
|
|
2738
|
+
export function expandImagePositionRefs(
|
|
2739
|
+
prompt: string,
|
|
2740
|
+
imageCount: number,
|
|
2741
|
+
names?: readonly string[],
|
|
2742
|
+
): string {
|
|
2743
|
+
return prompt.replace(IMAGE_TOKEN_PATTERN, (match, num, label) => {
|
|
2744
|
+
const n = parseInt(num, 10)
|
|
2745
|
+
if (n < 1 || n > imageCount) return match
|
|
2746
|
+
if (label) return `Image ${n} (${label})`
|
|
2747
|
+
if (names && names[n - 1]) return names[n - 1]
|
|
2748
|
+
return `Image ${n}`
|
|
2749
|
+
})
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
/** @deprecated Use `buildIdentityDirectives(prompt, refs, meta)`. */
|
|
2753
|
+
export function buildReferenceBlocks(
|
|
2754
|
+
refs: readonly ConnectedReference[],
|
|
2755
|
+
_meta: readonly IdentityMeta[] = [],
|
|
2756
|
+
): string {
|
|
2757
|
+
// Legacy callers don't have prompt context — emit a default-label block per ref.
|
|
2758
|
+
const synthPrompt = ""
|
|
2759
|
+
return buildIdentityDirectives(synthPrompt, refs, _meta)
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// ---------------------------------------------------------------------------
|
|
2763
|
+
// Scene prompt builder — shared between frontend DAG executor and backend
|
|
2764
|
+
// orchestrator. Builds a rich image-generation prompt from SceneNodeData.
|
|
2765
|
+
// ---------------------------------------------------------------------------
|
|
2766
|
+
|
|
2767
|
+
export const SCENE_PROMPT_MAX_LENGTH = 2000
|
|
2768
|
+
const SCENE_PROMPT_SAFE_LENGTH = 1800
|
|
2769
|
+
|
|
2770
|
+
export const SHOT_LABELS: Record<string, string> = {
|
|
2771
|
+
"extreme-wide": "EXTREME WIDE SHOT",
|
|
2772
|
+
"wide": "WIDE SHOT",
|
|
2773
|
+
"medium-wide": "MEDIUM WIDE SHOT",
|
|
2774
|
+
"medium": "MEDIUM SHOT",
|
|
2775
|
+
"medium-close": "MEDIUM CLOSE-UP",
|
|
2776
|
+
"close-up": "CLOSE-UP",
|
|
2777
|
+
"extreme-close-up": "EXTREME CLOSE-UP",
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
export const ANGLE_LABELS: Record<string, string> = {
|
|
2781
|
+
"eye-level": "eye level",
|
|
2782
|
+
"low-angle": "low angle",
|
|
2783
|
+
"high-angle": "high angle",
|
|
2784
|
+
"birds-eye": "bird's eye view",
|
|
2785
|
+
"worms-eye": "worm's eye view",
|
|
2786
|
+
"dutch": "dutch angle",
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
export const ASPECT_RATIO_LABELS: Record<string, string> = {
|
|
2790
|
+
"16:9": "wide landscape composition",
|
|
2791
|
+
"9:16": "vertical portrait composition",
|
|
2792
|
+
"1:1": "square composition",
|
|
2793
|
+
"4:3": "classic frame composition",
|
|
2794
|
+
"21:9": "ultrawide cinematic composition",
|
|
2795
|
+
"4:5": "tall portrait composition",
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
export const MOVEMENT_LABELS: Record<string, string> = {
|
|
2799
|
+
static: "static camera",
|
|
2800
|
+
pan: "camera panning",
|
|
2801
|
+
tilt: "camera tilting",
|
|
2802
|
+
dolly: "dolly shot",
|
|
2803
|
+
tracking: "tracking shot",
|
|
2804
|
+
crane: "crane shot",
|
|
2805
|
+
handheld: "handheld camera",
|
|
2806
|
+
zoom: "zoom",
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
export function truncateText(text: string, maxLen: number): string {
|
|
2810
|
+
if (text.length <= maxLen) return text
|
|
2811
|
+
return text.slice(0, maxLen - 3) + "..."
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
/**
|
|
2815
|
+
* Build a rich image-generation prompt from scene node data + character
|
|
2816
|
+
* definitions. Used by both the frontend DAG executor and the backend
|
|
2817
|
+
* orchestrator so that scene prompts are identical regardless of
|
|
2818
|
+
* execution path.
|
|
2819
|
+
*/
|
|
2820
|
+
export function buildScenePrompt(
|
|
2821
|
+
data: SceneData,
|
|
2822
|
+
assets: readonly CharacterDef[],
|
|
2823
|
+
options?: { forDisplay?: boolean },
|
|
2824
|
+
): string {
|
|
2825
|
+
const noTruncate = options?.forDisplay === true
|
|
2826
|
+
const t = noTruncate ? (text: string, _max: number) => text : truncateText
|
|
2827
|
+
const highParts: string[] = []
|
|
2828
|
+
const medParts: string[] = []
|
|
2829
|
+
const lowParts: string[] = []
|
|
2830
|
+
|
|
2831
|
+
// Shot type and angle (high)
|
|
2832
|
+
const shot = SHOT_LABELS[data.shotType] ?? "MEDIUM SHOT"
|
|
2833
|
+
const angle = ANGLE_LABELS[data.cameraAngle] ?? "eye level"
|
|
2834
|
+
highParts.push(`${shot}, ${angle}`)
|
|
2835
|
+
|
|
2836
|
+
// Aspect ratio composition hint (medium)
|
|
2837
|
+
if (data.aspectRatio && data.aspectRatio !== "16:9") {
|
|
2838
|
+
const ratioLabel = ASPECT_RATIO_LABELS[data.aspectRatio]
|
|
2839
|
+
if (ratioLabel) medParts.push(ratioLabel)
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
// Characters with mood and action (high, but truncate descriptions)
|
|
2843
|
+
if (data.characters.length > 0) {
|
|
2844
|
+
const maxDescLen = data.characters.length > 2 ? 80 : 150
|
|
2845
|
+
const charDescs = data.characters.map((entry) => {
|
|
2846
|
+
const asset = assets.find((a) => a.id === entry.assetId)
|
|
2847
|
+
const name = asset?.name ?? "a figure"
|
|
2848
|
+
const desc = asset?.description ? `, ${t(asset.description, maxDescLen)}` : ""
|
|
2849
|
+
const mood = entry.mood ? `, ${entry.mood}` : ""
|
|
2850
|
+
const action = entry.action ? ` ${entry.action}` : ""
|
|
2851
|
+
const pos = entry.positionInFrame ? ` (${entry.positionInFrame})` : ""
|
|
2852
|
+
return `${name}${desc}${mood}${action}${pos}`
|
|
2853
|
+
})
|
|
2854
|
+
highParts.push(`of ${charDescs.join(" and ")}`)
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
// Locations (high, but truncate names)
|
|
2858
|
+
if (data.locations && data.locations.length > 0) {
|
|
2859
|
+
const locDescs = data.locations.map((loc) => {
|
|
2860
|
+
const asset = assets.find((a) => a.id === loc.assetId)
|
|
2861
|
+
const rawName = loc.name ?? asset?.description ?? asset?.name ?? "location"
|
|
2862
|
+
const name = t(rawName, 120)
|
|
2863
|
+
const envParts: string[] = []
|
|
2864
|
+
const tod = loc.timeOfDay ?? data.timeOfDay
|
|
2865
|
+
const wth = loc.weather ?? data.weather
|
|
2866
|
+
const lit = loc.lighting ?? data.lighting
|
|
2867
|
+
if (tod !== "noon") envParts.push(`${tod} light`)
|
|
2868
|
+
if (wth !== "clear") envParts.push(wth)
|
|
2869
|
+
if (lit !== "natural") envParts.push(`${lit} lighting`)
|
|
2870
|
+
return envParts.length > 0 ? `${name} (${envParts.join(", ")})` : name
|
|
2871
|
+
})
|
|
2872
|
+
highParts.push(`in ${locDescs.join(" and ")}`)
|
|
2873
|
+
} else {
|
|
2874
|
+
const envParts: string[] = []
|
|
2875
|
+
if (data.timeOfDay !== "noon") envParts.push(`${data.timeOfDay} light`)
|
|
2876
|
+
if (data.weather !== "clear") envParts.push(data.weather)
|
|
2877
|
+
if (data.lighting !== "natural") envParts.push(`${data.lighting} lighting`)
|
|
2878
|
+
if (envParts.length > 0) highParts.push(envParts.join(", "))
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
// Objects (medium)
|
|
2882
|
+
if (data.objects.length > 0) {
|
|
2883
|
+
const objDescs = data.objects.map((o) => {
|
|
2884
|
+
const asset = assets.find((a) => a.id === o.assetId)
|
|
2885
|
+
return o.description ?? asset?.name ?? "object"
|
|
2886
|
+
})
|
|
2887
|
+
medParts.push(`with ${objDescs.join(", ")}`)
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
// Mood (medium)
|
|
2891
|
+
if (data.mood.length > 0) {
|
|
2892
|
+
medParts.push(`${data.mood.join(", ")} atmosphere`)
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// Visual style (medium)
|
|
2896
|
+
if (data.visualStyle) {
|
|
2897
|
+
medParts.push(`${data.visualStyle} style`)
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
// Depth of field (low)
|
|
2901
|
+
if (data.depthOfField !== "medium") {
|
|
2902
|
+
lowParts.push(`${data.depthOfField} depth of field`)
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
// Lens (low)
|
|
2906
|
+
if (data.lensType !== "normal") {
|
|
2907
|
+
lowParts.push(`${data.lensType} lens`)
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
// Camera movement (medium)
|
|
2911
|
+
if (data.cameraMovement !== "static") {
|
|
2912
|
+
medParts.push(MOVEMENT_LABELS[data.cameraMovement] ?? data.cameraMovement)
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
// Color palette (low)
|
|
2916
|
+
if (data.colorPalette.length > 0) {
|
|
2917
|
+
lowParts.push(`${data.colorPalette.join(", ")} color palette`)
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
// Summary as additional context (medium, truncated)
|
|
2921
|
+
if (data.summary.trim()) {
|
|
2922
|
+
medParts.push(t(data.summary.trim(), 300))
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
// Dialogue context (low, truncated)
|
|
2926
|
+
if (data.dialogue && data.dialogue.length > 0) {
|
|
2927
|
+
const dialogueDesc = data.dialogue
|
|
2928
|
+
.filter((d) => d.text.trim())
|
|
2929
|
+
.map((d) => `${d.characterName}${d.emotion ? ` (${d.emotion})` : ""}: "${t(d.text.trim(), 80)}"`)
|
|
2930
|
+
.join("; ")
|
|
2931
|
+
if (dialogueDesc) lowParts.push(`dialogue: ${t(dialogueDesc, 250)}`)
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
// Director notes (low, truncated)
|
|
2935
|
+
if (data.directorNotes?.trim()) {
|
|
2936
|
+
lowParts.push(t(data.directorNotes.trim(), 200))
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// Assemble with progressive dropping
|
|
2940
|
+
let result = [...highParts, ...medParts, ...lowParts].join(", ")
|
|
2941
|
+
|
|
2942
|
+
if (!noTruncate) {
|
|
2943
|
+
if (result.length > SCENE_PROMPT_SAFE_LENGTH) {
|
|
2944
|
+
let dropCount = 0
|
|
2945
|
+
while (dropCount < lowParts.length && result.length > SCENE_PROMPT_SAFE_LENGTH) {
|
|
2946
|
+
dropCount++
|
|
2947
|
+
result = [...highParts, ...medParts, ...lowParts.slice(0, lowParts.length - dropCount)].join(", ")
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
if (result.length > SCENE_PROMPT_SAFE_LENGTH) {
|
|
2951
|
+
const medRemaining = [...medParts]
|
|
2952
|
+
while (medRemaining.length > 0 && result.length > SCENE_PROMPT_SAFE_LENGTH) {
|
|
2953
|
+
medRemaining.pop()
|
|
2954
|
+
result = [...highParts, ...medRemaining].join(", ")
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
if (result.length > SCENE_PROMPT_MAX_LENGTH) {
|
|
2958
|
+
result = result.slice(0, SCENE_PROMPT_MAX_LENGTH - 3) + "..."
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
return result
|
|
2963
|
+
}
|
|
2964
|
+
|