@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.
Files changed (150) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +27 -0
  3. package/dist/index.cjs +21240 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +3599 -0
  6. package/dist/index.d.ts +3599 -0
  7. package/dist/index.js +20854 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +49 -0
  10. package/src/__tests__/__snapshots__/prompt-builder-segments.test.ts.snap +65 -0
  11. package/src/__tests__/action-fx.test.ts +155 -0
  12. package/src/__tests__/apply-picker-json.test.ts +50 -0
  13. package/src/__tests__/assemble-image-input.test.ts +248 -0
  14. package/src/__tests__/assemble-suno-input.test.ts +283 -0
  15. package/src/__tests__/brand-tokens.test.ts +81 -0
  16. package/src/__tests__/build-image-prompt-element-injection.test.ts +118 -0
  17. package/src/__tests__/build-image-prompt-hybrid-format.test.ts +131 -0
  18. package/src/__tests__/build-image-prompt-mentions.test.ts +809 -0
  19. package/src/__tests__/build-image-prompt-reference-cap.test.ts +57 -0
  20. package/src/__tests__/build-image-prompt-reference-numbering.test.ts +237 -0
  21. package/src/__tests__/build-image-prompt-reference-order.test.ts +313 -0
  22. package/src/__tests__/camera-motions-from-connections.test.ts +71 -0
  23. package/src/__tests__/catalog-gapfill.test.ts +53 -0
  24. package/src/__tests__/character-convergence-image.test.ts +217 -0
  25. package/src/__tests__/character-default-role-image.test.ts +167 -0
  26. package/src/__tests__/character-default-role-video.test.ts +168 -0
  27. package/src/__tests__/character-default-role.test.ts +70 -0
  28. package/src/__tests__/character-fx.test.ts +200 -0
  29. package/src/__tests__/entity-prompts-location.test.ts +97 -0
  30. package/src/__tests__/entity-prompts.test.ts +240 -0
  31. package/src/__tests__/expand-extra-refs-role.test.ts +50 -0
  32. package/src/__tests__/factory-presets.test.ts +1045 -0
  33. package/src/__tests__/factory-snippets.test.ts +68 -0
  34. package/src/__tests__/framing-multi.test.ts +71 -0
  35. package/src/__tests__/framing-vantage.test.ts +39 -0
  36. package/src/__tests__/i18n-entry-completeness.test.ts +269 -0
  37. package/src/__tests__/identity-lock.test.ts +46 -0
  38. package/src/__tests__/instrumentation.test.ts +68 -0
  39. package/src/__tests__/lighting-multi.test.ts +68 -0
  40. package/src/__tests__/location-convergence-image.test.ts +124 -0
  41. package/src/__tests__/mention-lock-flag.test.ts +499 -0
  42. package/src/__tests__/multi-picker-spec.test.ts +71 -0
  43. package/src/__tests__/music-genre.test.ts +103 -0
  44. package/src/__tests__/music-mood.test.ts +87 -0
  45. package/src/__tests__/object-creature-convergence-image.test.ts +105 -0
  46. package/src/__tests__/parameter-prompt-hint.test.ts +270 -0
  47. package/src/__tests__/parameter-registry-sync.test.ts +243 -0
  48. package/src/__tests__/person-age.test.ts +80 -0
  49. package/src/__tests__/person-analyzer-invariants.test.ts +47 -0
  50. package/src/__tests__/person-body-axes.test.ts +67 -0
  51. package/src/__tests__/person-facial-geometry.test.ts +156 -0
  52. package/src/__tests__/person-regional-aesthetic.test.ts +161 -0
  53. package/src/__tests__/person-sections.test.ts +18 -0
  54. package/src/__tests__/picker-analyzer-registry.test.ts +108 -0
  55. package/src/__tests__/picker-catalogs-project.test.ts +85 -0
  56. package/src/__tests__/picker-catalogs.test.ts +53 -0
  57. package/src/__tests__/picker-limits.test.ts +20 -0
  58. package/src/__tests__/prompt-builder-segments.test.ts +183 -0
  59. package/src/__tests__/prompt-builder-structured-fields.test.ts +40 -0
  60. package/src/__tests__/prompt-builder.test.ts +1773 -0
  61. package/src/__tests__/prompt-wizard-categories.test.ts +31 -0
  62. package/src/__tests__/provider-prompt-doctrine.test.ts +49 -0
  63. package/src/__tests__/resolve-prompt-append.test.ts +58 -0
  64. package/src/__tests__/resolve-prompt.test.ts +44 -0
  65. package/src/__tests__/role-picker-shared.test.ts +208 -0
  66. package/src/__tests__/seedance-2-inputs.test.ts +173 -0
  67. package/src/__tests__/seedance-extend.test.ts +44 -0
  68. package/src/__tests__/sound-aggregator.test.ts +350 -0
  69. package/src/__tests__/style-presets.test.ts +34 -0
  70. package/src/__tests__/temporal-multi.test.ts +74 -0
  71. package/src/__tests__/transitions.test.ts +213 -0
  72. package/src/__tests__/video-reference-features.test.ts +41 -0
  73. package/src/__tests__/video-reference-leading-refs.test.ts +90 -0
  74. package/src/__tests__/video-reference-resolver.test.ts +233 -0
  75. package/src/__tests__/video-reference-roles.test.ts +96 -0
  76. package/src/__tests__/voice-character.test.ts +48 -0
  77. package/src/__tests__/voice-delivery.test.ts +41 -0
  78. package/src/__tests__/wardrobe.test.ts +25 -0
  79. package/src/action-fx.ts +255 -0
  80. package/src/aesthetic.ts +435 -0
  81. package/src/assemble-image-input.ts +236 -0
  82. package/src/assemble-suno-input.ts +147 -0
  83. package/src/atmosphere.ts +104 -0
  84. package/src/backdrop.ts +131 -0
  85. package/src/brand-tokens.ts +154 -0
  86. package/src/camera-format.ts +76 -0
  87. package/src/camera-motions.ts +615 -0
  88. package/src/character-fx.ts +274 -0
  89. package/src/color-look.ts +103 -0
  90. package/src/composition-effects.ts +67 -0
  91. package/src/entity-prompts.ts +231 -0
  92. package/src/era.ts +292 -0
  93. package/src/exposure-settings.ts +142 -0
  94. package/src/factory-presets/generate-image.ts +1644 -0
  95. package/src/factory-presets/generate-video.ts +1116 -0
  96. package/src/factory-presets/index.ts +46 -0
  97. package/src/factory-presets/lottie-overlay.ts +166 -0
  98. package/src/factory-presets/motion-graphics.ts +350 -0
  99. package/src/factory-presets/music.ts +734 -0
  100. package/src/factory-presets/sfx.ts +136 -0
  101. package/src/factory-presets/shared-image.ts +207 -0
  102. package/src/factory-presets/switchx.ts +65 -0
  103. package/src/factory-presets/text.ts +292 -0
  104. package/src/factory-presets/types.ts +51 -0
  105. package/src/factory-presets/video-edit.ts +136 -0
  106. package/src/factory-presets/voice.ts +172 -0
  107. package/src/factory-presets.ts +2 -0
  108. package/src/factory-snippets/catalog.ts +105 -0
  109. package/src/factory-snippets/index.ts +16 -0
  110. package/src/factory-snippets/types.ts +33 -0
  111. package/src/framing.ts +634 -0
  112. package/src/held-prop.ts +187 -0
  113. package/src/identity-lock.ts +213 -0
  114. package/src/index.ts +66 -0
  115. package/src/instrumentation.ts +337 -0
  116. package/src/lens.ts +59 -0
  117. package/src/lighting.ts +229 -0
  118. package/src/loop-subject.ts +276 -0
  119. package/src/materials.ts +184 -0
  120. package/src/mood.ts +186 -0
  121. package/src/music-genre.ts +662 -0
  122. package/src/music-mood.ts +137 -0
  123. package/src/object-asset-presets.ts +81 -0
  124. package/src/parameter-prompt-hint.ts +281 -0
  125. package/src/person.ts +1368 -0
  126. package/src/photo-genre.ts +151 -0
  127. package/src/photographer.ts +612 -0
  128. package/src/picker-analyzer-registry.ts +374 -0
  129. package/src/picker-catalogs.ts +858 -0
  130. package/src/pose.ts +246 -0
  131. package/src/post-process-effects.ts +94 -0
  132. package/src/prompt-builder-structured-fields.ts +116 -0
  133. package/src/prompt-builder.ts +2964 -0
  134. package/src/prompt-templates.ts +50 -0
  135. package/src/prompt-wizard-categories.ts +334 -0
  136. package/src/provider-prompt-doctrine.ts +85 -0
  137. package/src/render-quality.ts +89 -0
  138. package/src/resolve-prompt.ts +120 -0
  139. package/src/seedance-2-inputs.ts +100 -0
  140. package/src/setting.ts +130 -0
  141. package/src/sound-aggregator.ts +241 -0
  142. package/src/style-presets.ts +162 -0
  143. package/src/style.ts +99 -0
  144. package/src/styling.ts +585 -0
  145. package/src/temporal.ts +151 -0
  146. package/src/transitions.ts +333 -0
  147. package/src/video-reference-resolver.ts +823 -0
  148. package/src/voice-character.ts +237 -0
  149. package/src/voice-delivery.ts +142 -0
  150. 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
+