@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,823 @@
1
+ /**
2
+ * Shared video-reference resolver CORE — the ONE pure implementation of the
3
+ * video-prompt `@-mention` + canonical-fallback + extras assembly that BOTH the
4
+ * frontend (`frontend/src/lib/video-prompt-assembly.ts`) and backend
5
+ * (`backend/src/services/workflow-engine/payload-builder.ts`) resolvers delegate
6
+ * to.
7
+ *
8
+ * Historically the FE and BE each hand-rolled a byte-for-byte copy of this logic
9
+ * (they had to stay in lock-step or single-node FE runs and orchestrator runs
10
+ * would diverge). This module is the lift-and-shift of the FRONTEND resolver's
11
+ * post-expansion body into one place so there is a single source of truth.
12
+ *
13
+ * Purity contract: NO FE/BE-only dependencies. The caller is responsible for the
14
+ * layer-specific work BEFORE calling in:
15
+ * - expanding wired Character upstreams into `ConnectedReference[]`
16
+ * (`wiredCharRefs`), and
17
+ * - looking up an extra-ref's character metadata by slug
18
+ * (`lookupCharacterBySlug` — FE: `nodes.find(...)`, BE: `buildCtx`).
19
+ *
20
+ * Behavior-preserving when first extracted (the FE resolver's post-expansion
21
+ * body was lifted verbatim). The ONE intended output change since is the
22
+ * reference-binding surface string: the per-image subject phrasing, the bullet
23
+ * ordinals, and the frame directive now emit `@image_N` / `@video_N` /
24
+ * `@audio_N` (the legacy form was `Image N`) — all routed through the single
25
+ * `REF_BINDING` swap-point below. The structural strings ("Use these
26
+ * characters:", the bullet layout, "… is the same subject as …") are otherwise
27
+ * unchanged.
28
+ */
29
+
30
+ import { DEFAULT_USAGE_MODE, usageModeDirective, type UsageMode } from "@nodaro/shared"
31
+ import { findCharacterMentionTokens, type CharacterMentionTokenInfo } from "@nodaro/shared"
32
+ import { resolveCharacterMentions, applyReferenceOrderToVideo } from "./prompt-builder.js"
33
+ import { roleToPhrase, REFERENCE_ROLE_PRESETS, resolveDefaultRole } from "@nodaro/shared"
34
+ import { buildIdentityLockLine, withForcedIdentityLock } from "./identity-lock.js"
35
+ import type { ConnectedReference } from "@nodaro/shared"
36
+
37
+ /**
38
+ * The SINGLE swap-point for the reference-binding surface-string (design D1/D7).
39
+ *
40
+ * Every place that renders an `@image_N`-style binding into a video prompt — the
41
+ * per-image subject phrasing, the bare ordinal in a "Use these characters" /
42
+ * pair-back bullet, and the opening/closing frame directive — MUST go through
43
+ * these five arrows. The default form is `@image_N`; if the D7 probe shows a
44
+ * provider prefers the legacy `Image N` form, flipping is editing ONLY these five
45
+ * arrows (`@image_${n}` → `Image ${n}`), nothing downstream.
46
+ *
47
+ * This IS the live swap-point: `resolveVideoReferenceCore` routes the per-image
48
+ * subject phrasing, the "Use these characters" / pair-back bullet ordinals, and
49
+ * the frame directive through these arrows, and `resolveReferenceTokens` resolves
50
+ * the body `{image:N}` tokens through `REF_BINDING[kind]` — so the five arrows
51
+ * are the ONLY emission sites for the binding surface string.
52
+ */
53
+ export const REF_BINDING = {
54
+ image: (label: string, n: number) => `the ${label} from @image_${n}`,
55
+ video: (label: string, n: number) => `the ${label} from @video_${n}`,
56
+ audio: (label: string, n: number) => `the ${label} from @audio_${n}`,
57
+ /** ordinal as it appears in a "Use these characters" bullet / pair-back */
58
+ ordinal: (n: number) => `@image_${n}`,
59
+ frame: (n: number, role: "opening" | "closing") =>
60
+ `Use @image_${n} as the ${role} (${role === "opening" ? "first" : "last"}) frame of the video.`,
61
+ } as const
62
+
63
+ /**
64
+ * Positional reference counts the editor tokens are resolved against — how many
65
+ * image / video / audio references are wired into the node, in worker-payload
66
+ * order. `{image:N:…}` is 1-based against `image`, `{video:N:…}` against `video`,
67
+ * etc. A token whose N exceeds its count (or whose kind has count 0) is dropped.
68
+ */
69
+ export interface ReferenceCounts {
70
+ image: number
71
+ video: number
72
+ audio: number
73
+ }
74
+
75
+ /** Matches an editor reference token: `{image:1}`, `{video:2:clip}`, `{audio:3:my song}`.
76
+ * Group 1 = kind, group 2 = 1-based slot N, group 3 = optional label
77
+ * (alphanumerics, underscore, space, hyphen). Case-insensitive on the kind. */
78
+ const REFERENCE_TOKEN_RE = /\{(image|video|audio):(\d+)(?::([a-zA-Z0-9_ -]+))?\}/gi
79
+
80
+ /**
81
+ * Rewrite the editor's `{image:N:label}` / `{video:N:label}` / `{audio:N:label}`
82
+ * reference tokens into `@image_N` / `@video_N` / `@audio_N` subject bindings.
83
+ *
84
+ * The token's `N` is POSITIONAL (1-based) against the matching `counts` entry —
85
+ * the worker-payload order of wired references of that kind. Per match:
86
+ * - `N < 1` or `N > counts[kind]` (out of range / no such reference) → drop to
87
+ * the bare `label` (or empty if label-less). This is the legacy
88
+ * `stripVideoImageTokens` strip behavior: drop the token, keep the label text.
89
+ * - in range, label present → `REF_BINDING[kind](label, N)`
90
+ * (e.g. `the person from @image_2`).
91
+ * - in range, no label → `the subject in @${kind}_${N}` so the binding still
92
+ * lands even when the author didn't name the subject.
93
+ *
94
+ * Runs of 2+ HORIZONTAL whitespace (left behind by a dropped label-less token)
95
+ * collapse to one space, the result is trimmed, and an empty result becomes
96
+ * `undefined` — matching the `stripVideoImageTokens` contract so this can replace
97
+ * it cleanly. The collapse class is `[^\S\r\n]` (NOT `\s`) on purpose: Task 2.4
98
+ * applies this to the FULLY-ASSEMBLED core prompt, which carries `\n\n` block
99
+ * separators between the "Use these characters:" directive block and the body —
100
+ * a `\s{2,}` collapse would silently merge those paragraphs. Horizontal-only
101
+ * collapse preserves newline structure while still tidying the dropped-token gap.
102
+ * The `kind` is lowercased before indexing `counts`/`REF_BINDING`, so a
103
+ * case-variant token (`{Image:1}`) resolves to the same binding rather than
104
+ * mis-classifying as out-of-range.
105
+ */
106
+ export function resolveReferenceTokens(
107
+ prompt: string | undefined,
108
+ counts: ReferenceCounts,
109
+ ): string | undefined {
110
+ if (!prompt) return prompt
111
+ return (
112
+ prompt
113
+ .replace(REFERENCE_TOKEN_RE, (_match, rawKind: string, nStr: string, label?: string) => {
114
+ const kind = rawKind.toLowerCase() as keyof ReferenceCounts
115
+ const n = parseInt(nStr, 10)
116
+ if (n < 1 || n > counts[kind]) return label ?? ""
117
+ if (label) return REF_BINDING[kind](label, n)
118
+ return `the subject in @${kind}_${n}`
119
+ })
120
+ // Horizontal whitespace only — preserve `\n` / `\n\n` block separators.
121
+ .replace(/[^\S\r\n]{2,}/g, " ")
122
+ .trim() || undefined
123
+ )
124
+ }
125
+
126
+ /**
127
+ * A user-attached "extra reference image" row. Layer-agnostic shape of the
128
+ * frontend `ExtraRef` / backend extras: only the fields this core reads.
129
+ */
130
+ export interface VideoExtraRef {
131
+ url: string
132
+ description?: string
133
+ characterSlug?: string
134
+ variantSlug?: string
135
+ usageMode?: UsageMode
136
+ /**
137
+ * Wired scene-composition fragment (held-prop / styling / text) for this
138
+ * extra — the `ConnectedReference.elementInjection` analogue. Surfaced ONLY
139
+ * in hybrid mode on a FIRST-SIGHT character extra, as its own trailing scene
140
+ * directive (mirrors the canonical/mention hybrid paths). Absent → unchanged.
141
+ */
142
+ elementInjection?: string | null
143
+ /**
144
+ * Optional, opt-in per-extra identity-lock (the `ConnectedReference.identityLock`
145
+ * analogue). Emitted ONLY in hybrid mode on a first-sight character extra, via
146
+ * `buildIdentityLockLine`. Default OFF (absent / `enabled === false`) → no lock.
147
+ * When absent, the first-sight branch falls back to the node's mapped lock
148
+ * from `CharacterMeta.identityLock`.
149
+ */
150
+ identityLock?: { enabled: boolean; text?: string }
151
+ /**
152
+ * The `ConnectedReference.defaultRole` analogue for ROUTE-supplied extras
153
+ * (generate-video / text-to-video / MCP verbs), whose adapter can only feed
154
+ * the COALESCED `defaultUsageMode` as `usageMode` — which would otherwise
155
+ * trip the meta-suppression gate and permanently ignore the node default.
156
+ * Preferred over `CharacterMeta.defaultRole` at the first-sight role site.
157
+ * The expander (`expandExtraRefsToConnectedReferences`) already suppresses
158
+ * the source field when a true per-ref override existed, so passing it
159
+ * through verbatim preserves the override-wins precedence. Canvas /
160
+ * orchestrator extras leave this unset (they resolve via `CharacterMeta`).
161
+ */
162
+ defaultRole?: string
163
+ }
164
+
165
+ /**
166
+ * Character metadata resolved by the caller for an extra-ref's `characterSlug`.
167
+ * Mirrors the per-layer upstream lookup: FE reads `CharacterNodeData`
168
+ * (`characterName` / `defaultUsageMode` / `canonicalDescription`); BE reads its
169
+ * `buildExtraRefCharacterContextLookup` context (`displayName` →
170
+ * `characterName`, etc.).
171
+ */
172
+ export interface CharacterMeta {
173
+ characterName?: string
174
+ defaultUsageMode?: UsageMode
175
+ canonicalDescription?: string
176
+ /** Character node's hybrid `defaultRole` — takes precedence over
177
+ * `defaultUsageMode` at the video extras first-sight role site (via
178
+ * `resolveDefaultRole`). Absent when the node never set a role. */
179
+ defaultRole?: string
180
+ /** Character node's `identityLock`, already mapped to the per-reference lock
181
+ * shape (`characterLockToRefLock`). Read at the video extras first-sight lock
182
+ * site; a per-extra `identityLock` still wins over it. */
183
+ identityLock?: { enabled: boolean; text?: string }
184
+ }
185
+
186
+ export interface ResolveVideoReferenceCoreArgs {
187
+ prompt: string | undefined
188
+ /** Already expanded by the caller's layer-specific expander. */
189
+ wiredCharRefs: ConnectedReference[]
190
+ extraRefs?: readonly VideoExtraRef[]
191
+ /** Look up an extra-ref's character metadata by slug (FE: nodes.find; BE: buildCtx). */
192
+ lookupCharacterBySlug?: (slug: string) => CharacterMeta | undefined
193
+ referenceOrder?: readonly string[]
194
+ suppressedCanonicalCharacterIds?: readonly string[]
195
+ /**
196
+ * Positional counts the editor numbers `{image:N}` / `{video:N}` / `{audio:N}`
197
+ * body tokens against — the TOTAL reference-handle count of each kind wired
198
+ * into the node (base reference images + mention additions), in worker-payload
199
+ * order. Supplied by the callers in Tasks 3.2/4.1; when omitted, `image` falls
200
+ * back to the core's own merged-URL count and `video`/`audio` to 0 (the core
201
+ * only attaches image URLs).
202
+ */
203
+ imageRefCount?: number
204
+ videoRefCount?: number
205
+ audioRefCount?: number
206
+ /**
207
+ * Plain image-reference URLs that LEAD the unified `@image_N` numbering
208
+ * (D5: image-refs-first). They occupy `@image_1 … @image_offset`; every
209
+ * character/asset directive this core emits is numbered AFTER them, and they
210
+ * are prepended to the returned `additionalUrls` so the worker payload order
211
+ * matches the ordinals. When provided, they OWN the image token count (so
212
+ * `imageRefCount` is ignored for the image modality); omitted → legacy
213
+ * behaviour (`imageRefCount ?? mergedLen`, asset URLs numbered from 1).
214
+ * Already deduped/reordered by the caller (e.g. `connectedRefImageOrder`).
215
+ */
216
+ leadingRefUrls?: readonly string[]
217
+ /**
218
+ * Number of leading image-refs the CALLER owns + merges itself (so the core
219
+ * offsets asset directive ordinals + the `{image:N}` count by this much WITHOUT
220
+ * prepending any URLs to `additionalUrls`). Used by i2v, whose frame-promotion
221
+ * consumes the asset URLs directly and can't have leading refs spliced in.
222
+ * Ignored when `leadingRefUrls` is set. D5 unified-asset-references.
223
+ */
224
+ ordinalOffset?: number
225
+ /**
226
+ * Render character / canonical-fallback / extra-ref directives in the hybrid
227
+ * role-phrase form (`the {role} from @image_N`) instead of the legacy
228
+ * `Use these characters:` block; default false = today's behavior, unchanged.
229
+ * Wired in Phase B Tasks 2-3.
230
+ */
231
+ hybridRoles?: boolean
232
+ }
233
+
234
+ /** Result of the HYBRID mention pass — inline role phrases + surfaced opt-in
235
+ * locks + wired element injections (no "Use these characters:" block). */
236
+ interface HybridMentionResult {
237
+ /** Body with each `@`-mention replaced INLINE by its role phrase
238
+ * (e.g. "the face from @image_1"). */
239
+ prompt: string
240
+ /** Matched character URLs in mention order, DEDUPED. The deduped order +
241
+ * `offset` define each URL's `@image_N` slot, so the per-mention binding and
242
+ * the core's `position` walk agree with the final merged URL list. */
243
+ additionalUrls: string[]
244
+ /** Slugs that had at least one resolved mention. */
245
+ mentionedCharacterSlugs: Set<string>
246
+ /** Per-reference opt-in identity-lock lines (non-null, one per unique URL).
247
+ * The caller prepends them as ONE block. Empty by default (opt-in). */
248
+ lockLines: string[]
249
+ /** Non-empty `elementInjection` fragments (one per unique URL). The caller
250
+ * appends them as trailing scene directives. */
251
+ elementDirectives: string[]
252
+ }
253
+
254
+ /**
255
+ * HYBRID-mode character mention convergence for the VIDEO core (Unified
256
+ * Reference Roles, Phase B — Task 2). The video analogue of
257
+ * `resolveCharacterMentionsHybrid` in `prompt-builder.ts`: where the legacy
258
+ * `resolveCharacterMentions` prepends the `"Use these characters:"` bullet block
259
+ * and replaces tokens with bare display names, this renders each `@`-mention as
260
+ * the inline role phrase `roleToPhrase(role, REF_BINDING.ordinal(slot))`
261
+ * (e.g. "the face from @image_1") and surfaces the optional identity-lock + the
262
+ * wired `elementInjection` SEPARATELY for the caller to assemble. No block.
263
+ *
264
+ * Slot/binding: a character URL's 1-based position in the DEDUPED mention-URL
265
+ * list, OFFSET by `offset` (the count of leading plain image-refs that precede
266
+ * the assets in the unified `@image_N` numbering — D5). Deduping keeps the
267
+ * binding aligned with both the core's `position` walk and the final merged URL
268
+ * list (a character mentioned N times attaches ONE URL → ONE slot).
269
+ *
270
+ * Role: the mention's `usageMode` (3rd-segment) — or `variantSlug` when no mode
271
+ * parsed — is the role when it's a curated `REFERENCE_ROLE_PRESETS["wired-character"]`
272
+ * entry (`person`/`face`/`clothes`/`hair`/`pose`/`expression`/`style`); otherwise
273
+ * the source default (`"person"`). Matching is variant-first, canonical-fallback,
274
+ * so a role-word 3rd segment that parsed as a variant slug still attaches the
275
+ * canonical reference instead of being dropped.
276
+ */
277
+ function resolveVideoCharacterMentionsHybrid(
278
+ prompt: string,
279
+ tokens: readonly CharacterMentionTokenInfo[],
280
+ refs: readonly ConnectedReference[],
281
+ offset: number,
282
+ ): HybridMentionResult {
283
+ const bySlug = new Map<string, ConnectedReference>()
284
+ const byVariant = new Map<string, ConnectedReference>()
285
+ for (const r of refs) {
286
+ if (!r.characterSlug) continue
287
+ if (!r.variantSlug) bySlug.set(r.characterSlug, r)
288
+ else byVariant.set(`${r.characterSlug}:${r.variantSlug}`, r)
289
+ }
290
+
291
+ const presets = REFERENCE_ROLE_PRESETS["wired-character"]
292
+
293
+ const mentionedCharacterSlugs = new Set<string>()
294
+ const refByUrl = new Map<string, ConnectedReference>()
295
+ // Per-mention `~lock` / `~nolock` (Task 4 + F4): the tri-state lock OVERRIDE
296
+ // per attached URL — `true` (force on) / `false` (force off, suppressing a
297
+ // ref-level enabled lock) / absent (inherit). Fed to `withForcedIdentityLock`
298
+ // before `buildIdentityLockLine`. Mirrors the image-side hybrid resolvers.
299
+ const lockOverrideByUrl = new Map<string, boolean>()
300
+ const matched: Array<{ token: string; offset: number; url: string; role: string }> = []
301
+ for (const t of tokens) {
302
+ // Variant-first, canonical-fallback. `variantMatch` (a REAL matched variant
303
+ // URL) doubles as the "this segment selected a variant, not a role" signal.
304
+ const variantMatch = t.variantSlug
305
+ ? byVariant.get(`${t.characterSlug}:${t.variantSlug}`)
306
+ : undefined
307
+ const match = variantMatch ?? bySlug.get(t.characterSlug)
308
+ if (!match || !match.url) continue
309
+ mentionedCharacterSlugs.add(t.characterSlug)
310
+ refByUrl.set(match.url, match)
311
+ if (t.lock !== undefined) lockOverrideByUrl.set(match.url, t.lock)
312
+ const segment = (t.usageMode ?? t.variantSlug ?? "").trim()
313
+ // Custom roles survive VERBATIM — the SAME relaxation as the image-side
314
+ // `resolveCharacterMentionsHybrid` (Unified Reference Roles, Phase D), kept
315
+ // in lock-step so FE single-node + orchestrator video runs never diverge. A
316
+ // preset OR a free-form value in the variant/role slot that didn't resolve to
317
+ // a real variant URL → role; a real variant slug or a directive-only usage
318
+ // mode → the NODE default (`defaultRole` verbatim → `defaultUsageMode`-derived
319
+ // → "person", via `resolveDefaultRole` — Character Node Role+Lock), mirroring
320
+ // the image-side mention fallback.
321
+ const role =
322
+ segment && (presets.includes(segment) || (t.usageMode == null && !variantMatch))
323
+ ? segment
324
+ : resolveDefaultRole(match.defaultRole, match.defaultUsageMode, "wired-character")
325
+ matched.push({ token: t.token, offset: t.offset, url: match.url, role })
326
+ }
327
+
328
+ // Deduped URL list + slot map together. Slot = offset + 1-based dedup index →
329
+ // `@image_N` (REF_BINDING.ordinal), so the binding lands AFTER the leading refs
330
+ // and aligns with the core's `position` walk over `additionalUrls`.
331
+ const additionalUrls: string[] = []
332
+ const slotByUrl = new Map<string, number>()
333
+ for (const m of matched) {
334
+ if (!slotByUrl.has(m.url)) {
335
+ slotByUrl.set(m.url, offset + slotByUrl.size + 1)
336
+ additionalUrls.push(m.url)
337
+ }
338
+ }
339
+ const bindingFor = (url: string): string => {
340
+ const slot = slotByUrl.get(url)
341
+ return slot !== undefined ? REF_BINDING.ordinal(slot) : REF_BINDING.ordinal(offset + 1)
342
+ }
343
+
344
+ // Replace mention tokens right-to-left so earlier offsets stay valid.
345
+ let resolvedPrompt = prompt
346
+ for (const m of [...matched].sort((a, b) => b.offset - a.offset)) {
347
+ const phrase = roleToPhrase(m.role, bindingFor(m.url))
348
+ resolvedPrompt =
349
+ resolvedPrompt.slice(0, m.offset) + phrase + resolvedPrompt.slice(m.offset + m.token.length)
350
+ }
351
+
352
+ // One opt-in lock + one element directive per UNIQUE attached URL.
353
+ const lockLines: string[] = []
354
+ const elementDirectives: string[] = []
355
+ const seenUrls = new Set<string>()
356
+ for (const m of matched) {
357
+ if (seenUrls.has(m.url)) continue
358
+ seenUrls.add(m.url)
359
+ const ref = refByUrl.get(m.url)
360
+ if (!ref) continue
361
+ const binding = bindingFor(m.url)
362
+ const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
363
+ if (lock) lockLines.push(lock)
364
+ const inject = ref.elementInjection?.trim()
365
+ if (inject) elementDirectives.push(inject)
366
+ }
367
+
368
+ return { prompt: resolvedPrompt, additionalUrls, mentionedCharacterSlugs, lockLines, elementDirectives }
369
+ }
370
+
371
+ /**
372
+ * Resolve `@kira:N` / `@kira:N:smile` mentions in a video-node prompt against
373
+ * the caller-supplied wired Character references AND apply the per-character
374
+ * canonical fallback for unmentioned wired characters, plus manual/extra refs.
375
+ *
376
+ * Per-character behavior contract (parity with image-side + backend):
377
+ * - wired-character with at least one `@-mention` → contribute ONLY the
378
+ * mentioned variant URLs (no canonical auto-attach), prepend the
379
+ * mention-derived directive block.
380
+ * - wired-character with NO `@-mention` → contribute the canonical URL
381
+ * + a strong identity directive (mode-aware via `defaultUsageMode`).
382
+ *
383
+ * Returns the mutated prompt + the asset URLs to slot into the worker payload.
384
+ * The caller decides where (i2v has both `imageUrl` and `referenceImageUrls`;
385
+ * v2v has only a single `referenceImageUrl`; t2v has `referenceImageUrls` only).
386
+ */
387
+ export function resolveVideoReferenceCore(
388
+ args: ResolveVideoReferenceCoreArgs,
389
+ ): { prompt: string | undefined; additionalUrls: string[] } {
390
+ // Counts the editor numbers `{image:N}` / `{video:N}` / `{audio:N}` body tokens
391
+ // against. `image` falls back to the core's own merged-URL count (passed by the
392
+ // caller as `imageFallback`); `video`/`audio` to 0 since the core attaches only
393
+ // image URLs. Applied at every return so token resolution is uniform.
394
+ // Leading plain image-refs (D5 image-refs-first): they occupy `@image_1 …
395
+ // @image_offset`, lead the merged URL list, and OWN the image token count.
396
+ // Deduped + trimmed defensively (the caller already orders them).
397
+ const leadingRefUrls: string[] = []
398
+ {
399
+ const seenLead = new Set<string>()
400
+ for (const u of args.leadingRefUrls ?? []) {
401
+ const t = u?.trim()
402
+ if (t && !seenLead.has(t)) { seenLead.add(t); leadingRefUrls.push(t) }
403
+ }
404
+ }
405
+ const hasLeading = args.leadingRefUrls != null
406
+ // `offset` = how many image-refs precede the assets in the unified numbering.
407
+ // Two ways to specify it: `leadingRefUrls` (the core OWNS the URLs — prepends
408
+ // them to `merged`, offset = their count) OR `ordinalOffset` (the CALLER owns
409
+ // the leading URLs and merges them itself — e.g. i2v's frame-promotion path —
410
+ // so the core only offsets the numbering + count, no prepend). They're mutually
411
+ // exclusive; `leadingRefUrls` wins if both are (mistakenly) passed.
412
+ const offset = hasLeading ? leadingRefUrls.length : (args.ordinalOffset ?? 0)
413
+ const hasUnified = hasLeading || args.ordinalOffset != null
414
+ // image token count: the FULL unified count = offset (leading) + asset count,
415
+ // where asset count = mergedLen − the core-owned leading URLs. (leadingRefUrls
416
+ // mode: offset == leadingRefUrls.length → count == mergedLen; ordinalOffset
417
+ // mode: leadingRefUrls is empty → count == offset + mergedLen.) Without a
418
+ // unified offset, keep the legacy `imageRefCount ??` fallback. video/audio
419
+ // always from args (this core attaches only image URLs).
420
+ const tokenCounts = (mergedLen: number): ReferenceCounts => ({
421
+ image: hasUnified ? (offset + mergedLen - leadingRefUrls.length) : (args.imageRefCount ?? mergedLen),
422
+ video: args.videoRefCount ?? 0,
423
+ audio: args.audioRefCount ?? 0,
424
+ })
425
+ let wiredCharRefs = [...args.wiredCharRefs]
426
+ const suppressedSlugs = new Set(args.suppressedCanonicalCharacterIds ?? [])
427
+ if (suppressedSlugs.size > 0) {
428
+ wiredCharRefs = wiredCharRefs.filter((r) => {
429
+ if (r.source !== "wired-character") return true
430
+ if (!r.characterSlug) return true
431
+ if (r.variantSlug) return true
432
+ return !suppressedSlugs.has(r.characterSlug)
433
+ })
434
+ }
435
+ // Extras are valid even WITHOUT any wired character upstream (e.g. the user
436
+ // uploaded loose reference photos and typed per-row descriptions). The
437
+ // early-return below is gated on (no chars AND no extras) so we don't skip
438
+ // extras-only setups.
439
+ const hasExtras = (args.extraRefs?.length ?? 0) > 0
440
+ if (wiredCharRefs.length === 0 && !hasExtras) {
441
+ // No wired chars / extras, but the node can still carry plain base reference
442
+ // images (leadingRefUrls), so `{image:N}` body tokens MUST still resolve. The
443
+ // count is the leading-ref count (or the legacy `imageRefCount` when no
444
+ // leading refs were passed); the leading URLs are returned for the payload.
445
+ return {
446
+ // tokenCounts(leadingRefUrls.length) → image count == offset (no assets here):
447
+ // leadingRefUrls mode counts the leading refs; ordinalOffset mode counts the
448
+ // caller-owned leading refs the offset stands in for.
449
+ prompt: resolveReferenceTokens(args.prompt, tokenCounts(leadingRefUrls.length)),
450
+ additionalUrls: [...leadingRefUrls],
451
+ }
452
+ }
453
+ const knownCharSlugs = Array.from(
454
+ new Set(
455
+ wiredCharRefs
456
+ .map((r) => r.characterSlug)
457
+ .filter((s): s is string => typeof s === "string" && s.length > 0),
458
+ ),
459
+ )
460
+ // Empty user prompt is allowed — canonical fallback / mention resolution
461
+ // can fill the prompt entirely. Treat undefined/empty as `""` so the
462
+ // resolver flows through to mention + canonical-fallback assembly below.
463
+ const promptForResolution = args.prompt ?? ""
464
+ const mentionTokens = knownCharSlugs.length > 0
465
+ ? findCharacterMentionTokens(promptForResolution, knownCharSlugs)
466
+ : []
467
+ // Hybrid (Phase B) emits inline role phrases (`the {role} from @image_N`) +
468
+ // surfaced opt-in locks / element injections; legacy emits the shared "Use
469
+ // these characters:" block. Gated on `args.hybridRoles` (default false →
470
+ // byte-identical legacy output).
471
+ const hybrid = args.hybridRoles === true
472
+ // Resolve any mentions (may be empty); always check fallback after.
473
+ let resolved: { prompt: string; additionalUrls: string[]; mentionedCharacterSlugs: Set<string> }
474
+ // Hybrid mention-pass byproducts (empty in legacy mode): the lock lines the
475
+ // caller prepends once + the element injections it appends as trailing lines.
476
+ let mentionLockLines: string[] = []
477
+ let mentionElementDirectives: string[] = []
478
+ if (hybrid) {
479
+ const h = resolveVideoCharacterMentionsHybrid(promptForResolution, mentionTokens, wiredCharRefs, offset)
480
+ resolved = { prompt: h.prompt, additionalUrls: h.additionalUrls, mentionedCharacterSlugs: h.mentionedCharacterSlugs }
481
+ mentionLockLines = h.lockLines
482
+ mentionElementDirectives = h.elementDirectives
483
+ } else {
484
+ resolved = mentionTokens.length > 0
485
+ ? resolveCharacterMentions(promptForResolution, mentionTokens, wiredCharRefs)
486
+ : { prompt: promptForResolution, additionalUrls: [] as string[], mentionedCharacterSlugs: new Set<string>() }
487
+ }
488
+
489
+ // Canonical fallback for any wired character NOT @-mentioned. Single
490
+ // canonical URL + strong directive per unmentioned character — mirrors
491
+ // `buildCanonicalFallback` from the shared prompt-builder and the backend
492
+ // `resolveVideoPromptMentions`. The directive's wording is mode-aware:
493
+ // resolves through the character node's `defaultUsageMode` → global
494
+ // `DEFAULT_USAGE_MODE` so a character configured for "face" emits a
495
+ // face-only directive instead of the identity-lock language.
496
+ const fallbackUrls: string[] = []
497
+ const fallbackDirectiveLines: string[] = []
498
+ // Hybrid canonical-fallback byproducts (empty in legacy mode): the role
499
+ // phrases appended to the body, the opt-in lock lines, and the wired element
500
+ // injections — mirrors `renderCanonicalFallbackHybrid` in prompt-builder.ts.
501
+ const canonicalPhrases: string[] = []
502
+ const canonicalLockLines: string[] = []
503
+ const canonicalElementDirectives: string[] = []
504
+ const seenSlugs = new Set<string>()
505
+ // Character-slug → first emitted position, used by extras to pair back via
506
+ // "Image B is the same subject as Image A, …". Built from mention URLs +
507
+ // canonical fallback URLs as they're emitted.
508
+ const positionsByChar = new Map<string, number>()
509
+ // `position` walks the FINAL merged URL list (leading plain refs first, then
510
+ // mention URLs, canonical fallback, then extras). Seeded past the leading refs
511
+ // (D5) so directive ordinals number AFTER them and align with the worker's
512
+ // `referenceImageUrls` order.
513
+ let position = offset
514
+ for (let i = 0; i < resolved.additionalUrls.length; i++) {
515
+ position += 1
516
+ // Look up which ref this URL came from to learn its characterSlug.
517
+ const ref = wiredCharRefs.find((r) => r.url === resolved.additionalUrls[i])
518
+ const slug = ref?.characterSlug
519
+ if (slug && !positionsByChar.has(slug)) positionsByChar.set(slug, position)
520
+ }
521
+ for (const r of wiredCharRefs) {
522
+ if (r.source !== "wired-character") continue
523
+ if (!r.characterSlug) continue
524
+ if (resolved.mentionedCharacterSlugs.has(r.characterSlug)) continue
525
+ if (seenSlugs.has(r.characterSlug)) continue
526
+ if (r.variantSlug) continue
527
+ if (!r.url) continue
528
+ seenSlugs.add(r.characterSlug)
529
+ fallbackUrls.push(r.url)
530
+ position += 1
531
+ if (!positionsByChar.has(r.characterSlug)) positionsByChar.set(r.characterSlug, position)
532
+ // Hybrid: emit the inline role phrase (`the person from @image_N`) + opt-in
533
+ // lock + wired element injection instead of a "Use these characters:"
534
+ // bullet. The selection above (canonical entry only, deduped, skip mentioned)
535
+ // is shared with the legacy branch so both paths attach the SAME URLs.
536
+ if (hybrid) {
537
+ const binding = REF_BINDING.ordinal(position)
538
+ // Node-default role chain (Character Node Role+Lock): `defaultRole`
539
+ // (hybrid dropdown pick, verbatim) → `defaultUsageMode`-derived → source
540
+ // default — mirroring the image `renderCanonicalFallbackHybrid`.
541
+ canonicalPhrases.push(roleToPhrase(resolveDefaultRole(r.defaultRole, r.defaultUsageMode, r.source), binding))
542
+ const lock = buildIdentityLockLine(r, binding)
543
+ if (lock) canonicalLockLines.push(lock)
544
+ const inject = r.elementInjection?.trim()
545
+ if (inject) canonicalElementDirectives.push(inject)
546
+ continue
547
+ }
548
+ const displayName = r.defaultName || r.characterSlug
549
+ const effectiveMode = r.defaultUsageMode ?? DEFAULT_USAGE_MODE
550
+ // Minimal-intervention modes:
551
+ // - "none": URL attached, NO bullet emitted.
552
+ // - "name": one bullet with the name, no trailing directive.
553
+ if (effectiveMode === "none") {
554
+ continue
555
+ }
556
+ if (effectiveMode === "name") {
557
+ fallbackDirectiveLines.push(`- ${REF_BINDING.ordinal(position)} (${displayName})`)
558
+ continue
559
+ }
560
+ const directive = usageModeDirective(effectiveMode)
561
+ const includeCanonicalDesc = effectiveMode === "identical" || effectiveMode === "face-pose"
562
+ // Wired elements ride the bullet alongside the (mode-gated) canonical desc —
563
+ // mirrors the shared image-side `composeIdentityDescPart`. The subject here
564
+ // is the bare display name (video numbering is applied separately above).
565
+ const descBodyParts: string[] = []
566
+ if (includeCanonicalDesc && r.characterCanonicalDescription?.trim()) {
567
+ descBodyParts.push(r.characterCanonicalDescription.trim())
568
+ }
569
+ if (r.elementInjection?.trim()) descBodyParts.push(r.elementInjection.trim())
570
+ const descPart = descBodyParts.length > 0
571
+ ? `${displayName} — ${descBodyParts.join(". ")}`
572
+ : displayName
573
+ fallbackDirectiveLines.push(`- ${descPart}.${directive ? ` ${directive}` : ""}`)
574
+ }
575
+
576
+ // Extras: emit one directive per row. Numbering continues from `position`
577
+ // so the worker's `referenceImageUrls` order lines up with `@image_N` in
578
+ // the assembled prompt. Pair-back ("same subject as @image_M, …") fires
579
+ // when the same `characterSlug` was already attached as a mention or
580
+ // canonical fallback.
581
+ //
582
+ // Two render shapes, gated on `hybrid`:
583
+ // - legacy (`!hybrid`) → `- @image_N (reference): …` / `- @image_N is the
584
+ // same subject as @image_M.` bullets in `extraDirectiveLines` (byte-
585
+ // identical to today; consolidated into the "Use these characters:" block).
586
+ // - hybrid → trailing role-phrase / pair-back / manual directives in
587
+ // `extraHybridLines` (+ opt-in lock lines + first-sight element
588
+ // injections), mirroring Task 2's canonical branch. NO block, no bullets.
589
+ const extraUrls: string[] = []
590
+ const extraDirectiveLines: string[] = []
591
+ // Hybrid extras byproducts (empty in legacy mode): trailing directives, the
592
+ // opt-in identity-lock lines (prepended once with the mention/canonical locks),
593
+ // and first-sight wired element injections (appended as trailing scene lines).
594
+ const extraHybridLines: string[] = []
595
+ const extraHybridLockLines: string[] = []
596
+ const extraHybridElementDirectives: string[] = []
597
+ if (hasExtras) {
598
+ for (const ex of args.extraRefs!) {
599
+ if (!ex.url) continue
600
+ position += 1
601
+ const desc = (ex.description ?? "").trim()
602
+ if (ex.characterSlug) {
603
+ // First sight of this character via an extra. Resolution chain
604
+ // matches the image side: per-ref override → upstream character
605
+ // default → global identical. The caller supplies the upstream
606
+ // character's metadata via `lookupCharacterBySlug`.
607
+ const meta = args.lookupCharacterBySlug?.(ex.characterSlug)
608
+ const charDefaultMode = meta?.defaultUsageMode
609
+ const effectiveMode = ex.usageMode ?? charDefaultMode ?? DEFAULT_USAGE_MODE
610
+ const earlierPos = positionsByChar.get(ex.characterSlug)
611
+ if (hybrid) {
612
+ // Hybrid character extra — mirrors Task 2's mention/canonical role
613
+ // shape. Mode-agnostic (the role comes from the usageMode/variant
614
+ // mapping, not from a none/name/else split): the hybrid path never
615
+ // emits a "Use these characters:" bullet, so the legacy minimal-
616
+ // intervention suppression doesn't apply.
617
+ const binding = REF_BINDING.ordinal(position)
618
+ if (earlierPos !== undefined) {
619
+ // Pair-back: `@image_N is the same subject as @image_M[, <desc>].`
620
+ const tail = desc ? `, ${desc}` : ""
621
+ extraHybridLines.push(
622
+ `${binding} is the same subject as ${REF_BINDING.ordinal(earlierPos)}${tail}.`,
623
+ )
624
+ } else {
625
+ // First-sight: mapped role phrase + opt-in identity-lock + wired
626
+ // element injection (the latter two surfaced as their own lines,
627
+ // mirroring `renderCanonicalFallbackHybrid` / the mention hybrid).
628
+ // Role chain (Character Node Role+Lock): the node's `defaultRole`
629
+ // (`meta.defaultRole`, verbatim — applied only when the extra
630
+ // carries NO per-ref `usageMode` override, which would win) → the
631
+ // COALESCED effective mode (per-ref override → node default →
632
+ // "identical"), preset-mapped → source default — via the shared
633
+ // `resolveDefaultRole` helper. The image `renderExtraRefsHybrid`
634
+ // feeds the same helper the expander-stamped `defaultRole` +
635
+ // coalesced `defaultUsageMode`, so image and video stay fully
636
+ // converged (same helper, same precedence); pinned by
637
+ // `character-convergence-image.test.ts`.
638
+ const role = resolveDefaultRole(
639
+ // Per-extra `defaultRole` (route-supplied CR extras) wins; else
640
+ // the node default via meta — suppressed when the extra carries a
641
+ // true per-ref usageMode override (the override wins).
642
+ ex.defaultRole ?? (ex.usageMode ? undefined : meta?.defaultRole),
643
+ effectiveMode,
644
+ "wired-character",
645
+ )
646
+ const phrase = roleToPhrase(role, binding)
647
+ extraHybridLines.push(desc ? `${phrase}, ${desc}.` : `${phrase}.`)
648
+ // Lock: per-extra `identityLock` wins; else the node's mapped lock
649
+ // (`meta.identityLock`, from `characterLockToRefLock`).
650
+ const lock = buildIdentityLockLine(
651
+ {
652
+ id: ex.url,
653
+ defaultName: meta?.characterName || ex.characterSlug,
654
+ source: "wired-character",
655
+ url: ex.url,
656
+ characterSlug: ex.characterSlug,
657
+ variantSlug: ex.variantSlug,
658
+ identityLock: ex.identityLock ?? meta?.identityLock,
659
+ },
660
+ binding,
661
+ )
662
+ if (lock) extraHybridLockLines.push(lock)
663
+ const inject = ex.elementInjection?.trim()
664
+ if (inject) extraHybridElementDirectives.push(inject)
665
+ positionsByChar.set(ex.characterSlug, position)
666
+ }
667
+ } else if (earlierPos !== undefined) {
668
+ // Pair-back. Suppressed for "none" so the extras-side respects the
669
+ // same minimal-intervention contract as primary mentions.
670
+ if (effectiveMode !== "none") {
671
+ const tail = desc ? `, ${desc}` : ""
672
+ extraDirectiveLines.push(
673
+ `- ${REF_BINDING.ordinal(position)} is the same subject as ${REF_BINDING.ordinal(earlierPos)}${tail}.`,
674
+ )
675
+ }
676
+ } else if (effectiveMode === "none") {
677
+ // URL attached, no bullet. Record the slot for any later same-
678
+ // character extras that pair-back via "same subject as Image N".
679
+ positionsByChar.set(ex.characterSlug, position)
680
+ } else if (effectiveMode === "name") {
681
+ const displayName = meta?.characterName || ex.characterSlug
682
+ const subject = `${REF_BINDING.ordinal(position)} (${displayName})`
683
+ const descPart = desc ? `${subject} — ${desc}` : subject
684
+ extraDirectiveLines.push(`- ${descPart}.`)
685
+ positionsByChar.set(ex.characterSlug, position)
686
+ } else {
687
+ const directive = usageModeDirective(effectiveMode)
688
+ const displayName = meta?.characterName || ex.characterSlug
689
+ const subject = `${REF_BINDING.ordinal(position)} (${displayName})`
690
+ const includeCanonicalDesc = effectiveMode === "identical" || effectiveMode === "face-pose"
691
+ const canonicalDesc = meta?.canonicalDescription
692
+ let descPart = subject
693
+ if (desc) descPart = `${subject} — ${desc}`
694
+ else if (includeCanonicalDesc && canonicalDesc?.trim()) descPart = `${subject} — ${canonicalDesc.trim()}`
695
+ extraDirectiveLines.push(`- ${descPart}.${directive ? ` ${directive}` : ""}`)
696
+ positionsByChar.set(ex.characterSlug, position)
697
+ }
698
+ } else if (hybrid) {
699
+ // Hybrid manual extra: surface the description tied to its `@image_N`
700
+ // ("<desc> (@image_N).") as a trailing directive; a description-less
701
+ // extra still emits a bare positional marker so the model knows what
702
+ // `@image_N` is. No bullet, no block.
703
+ const binding = REF_BINDING.ordinal(position)
704
+ extraHybridLines.push(desc ? `${desc} (${binding}).` : `${binding} (reference).`)
705
+ } else {
706
+ // Manual extra. Description goes in the bullet; absent description
707
+ // still emits a positional marker so the model knows what Image N is.
708
+ if (desc) {
709
+ extraDirectiveLines.push(`- ${REF_BINDING.ordinal(position)} (reference): ${desc}.`)
710
+ } else {
711
+ extraDirectiveLines.push(`- ${REF_BINDING.ordinal(position)} (reference).`)
712
+ }
713
+ }
714
+ extraUrls.push(ex.url)
715
+ }
716
+ }
717
+
718
+ let finalPrompt = resolved.prompt
719
+ if (hybrid) {
720
+ // Hybrid assembly (mirrors `buildImagePrompt`'s hybrid branch): prepend ONE
721
+ // identity-lock block (mention + canonical + extra first-sight, opt-in →
722
+ // usually empty) and append the body's trailing scene directives (mention
723
+ // element injections, canonical role phrases, canonical element injections,
724
+ // then the extras' hybrid directives + their first-sight element
725
+ // injections). Every extra line carries an `@image_N` binding, so the
726
+ // numbering stays consistent with the core's `position` walk. The
727
+ // `"Use these characters:"` block is NEVER assembled here.
728
+ // Set-dedup — mirrors the image assembler: identical lock lines (same ref
729
+ // locked via mention + canonical/extra) are emitted once. {ref}-bound
730
+ // texts keep distinct references distinct.
731
+ const lockLines = [...new Set([...mentionLockLines, ...canonicalLockLines, ...extraHybridLockLines])]
732
+ const trailingLines = [
733
+ ...mentionElementDirectives,
734
+ ...canonicalPhrases,
735
+ ...canonicalElementDirectives,
736
+ ...extraHybridLines,
737
+ ...extraHybridElementDirectives,
738
+ ]
739
+ const lockBlock = lockLines.length > 0 ? `${lockLines.join("\n")}\n\n` : ""
740
+ const trailingBlock = trailingLines.length > 0 ? `\n${trailingLines.join("\n")}` : ""
741
+ finalPrompt = `${lockBlock}${finalPrompt}${trailingBlock}`
742
+ } else {
743
+ const allFallbackLines = [...fallbackDirectiveLines, ...extraDirectiveLines]
744
+ if (allFallbackLines.length > 0) {
745
+ // Mirror shared `buildImagePrompt`'s consolidation: append fallback
746
+ // bullets into an existing "Use these characters:" block when present,
747
+ // otherwise create a new one.
748
+ if (finalPrompt && finalPrompt.startsWith("Use these characters:\n")) {
749
+ const splitIdx = finalPrompt.indexOf("\n\n")
750
+ if (splitIdx !== -1) {
751
+ const header = finalPrompt.slice(0, splitIdx)
752
+ const rest = finalPrompt.slice(splitIdx)
753
+ finalPrompt = `${header}\n${allFallbackLines.join("\n")}${rest}`
754
+ } else {
755
+ finalPrompt = `${finalPrompt}\n${allFallbackLines.join("\n")}`
756
+ }
757
+ } else {
758
+ const block = `Use these characters:\n${allFallbackLines.join("\n")}`
759
+ finalPrompt = finalPrompt ? `${block}\n\n${finalPrompt}` : block
760
+ }
761
+ }
762
+ }
763
+
764
+ // Dedup combined URLs while preserving order (LEADING plain refs first, then
765
+ // mentions, fallback, then extras — D5). The `@image_N` labels in the prompt
766
+ // assume this exact order BEFORE any user-defined `referenceOrder` reorder below.
767
+ const merged: string[] = []
768
+ const seen = new Set<string>()
769
+ for (const u of leadingRefUrls) {
770
+ if (u && !seen.has(u)) { seen.add(u); merged.push(u) }
771
+ }
772
+ for (const u of resolved.additionalUrls) {
773
+ if (u && !seen.has(u)) { seen.add(u); merged.push(u) }
774
+ }
775
+ for (const u of fallbackUrls) {
776
+ if (u && !seen.has(u)) { seen.add(u); merged.push(u) }
777
+ }
778
+ for (const u of extraUrls) {
779
+ if (u && !seen.has(u)) { seen.add(u); merged.push(u) }
780
+ }
781
+
782
+ // Apply user-defined reorder + renumber `Image N` tokens — parity with the
783
+ // backend `resolveVideoPromptMentions` and the shared image builder.
784
+ const referenceOrder = args.referenceOrder
785
+ // Reorder ONLY the asset tail (merged minus the core-owned leading plain refs).
786
+ // Leading refs are already in the caller's chosen order and stay fixed; the
787
+ // renumber offsets asset ordinals by `offset` (D5). Slice by the core-owned
788
+ // leading count (0 in ordinalOffset mode, where the caller owns the leading URLs).
789
+ const assetUrls = merged.slice(leadingRefUrls.length)
790
+ if (referenceOrder && referenceOrder.length > 0 && assetUrls.length > 1) {
791
+ const refsForOrdering: ConnectedReference[] = [...wiredCharRefs]
792
+ if (hasExtras) {
793
+ for (const ex of args.extraRefs!) {
794
+ if (!ex.url) continue
795
+ refsForOrdering.push({
796
+ id: ex.url,
797
+ defaultName: ex.characterSlug || "Extra",
798
+ source: ex.characterSlug ? "wired-character" : "manual",
799
+ url: ex.url,
800
+ characterSlug: ex.characterSlug,
801
+ variantSlug: ex.variantSlug,
802
+ isExtraRef: true,
803
+ })
804
+ }
805
+ }
806
+ const reordered = applyReferenceOrderToVideo(assetUrls, finalPrompt, refsForOrdering, referenceOrder, undefined, offset)
807
+ // Resolve body tokens LAST — AFTER the reorder's `@image_N` renumber pass, so
808
+ // it can't miscorrect a freshly-resolved binding (the curly `{image:N}` tokens
809
+ // are invisible to the reorder's `(@image_|Image )` regex, so they ride through
810
+ // untouched and keep their author-typed N — documented v1 behavior).
811
+ return {
812
+ prompt: resolveReferenceTokens(reordered.prompt, tokenCounts(merged.length)),
813
+ additionalUrls: [...leadingRefUrls, ...reordered.urls],
814
+ }
815
+ }
816
+
817
+ // Same as the reorder branch but no user reorder ran — resolve the body tokens
818
+ // on the assembled prompt as the final step.
819
+ return {
820
+ prompt: resolveReferenceTokens(finalPrompt, tokenCounts(merged.length)),
821
+ additionalUrls: merged,
822
+ }
823
+ }