@nodaro/prompts 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/README.md +27 -0
- package/dist/index.cjs +21240 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3599 -0
- package/dist/index.d.ts +3599 -0
- package/dist/index.js +20854 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/__tests__/__snapshots__/prompt-builder-segments.test.ts.snap +65 -0
- package/src/__tests__/action-fx.test.ts +155 -0
- package/src/__tests__/apply-picker-json.test.ts +50 -0
- package/src/__tests__/assemble-image-input.test.ts +248 -0
- package/src/__tests__/assemble-suno-input.test.ts +283 -0
- package/src/__tests__/brand-tokens.test.ts +81 -0
- package/src/__tests__/build-image-prompt-element-injection.test.ts +118 -0
- package/src/__tests__/build-image-prompt-hybrid-format.test.ts +131 -0
- package/src/__tests__/build-image-prompt-mentions.test.ts +809 -0
- package/src/__tests__/build-image-prompt-reference-cap.test.ts +57 -0
- package/src/__tests__/build-image-prompt-reference-numbering.test.ts +237 -0
- package/src/__tests__/build-image-prompt-reference-order.test.ts +313 -0
- package/src/__tests__/camera-motions-from-connections.test.ts +71 -0
- package/src/__tests__/catalog-gapfill.test.ts +53 -0
- package/src/__tests__/character-convergence-image.test.ts +217 -0
- package/src/__tests__/character-default-role-image.test.ts +167 -0
- package/src/__tests__/character-default-role-video.test.ts +168 -0
- package/src/__tests__/character-default-role.test.ts +70 -0
- package/src/__tests__/character-fx.test.ts +200 -0
- package/src/__tests__/entity-prompts-location.test.ts +97 -0
- package/src/__tests__/entity-prompts.test.ts +240 -0
- package/src/__tests__/expand-extra-refs-role.test.ts +50 -0
- package/src/__tests__/factory-presets.test.ts +1045 -0
- package/src/__tests__/factory-snippets.test.ts +68 -0
- package/src/__tests__/framing-multi.test.ts +71 -0
- package/src/__tests__/framing-vantage.test.ts +39 -0
- package/src/__tests__/i18n-entry-completeness.test.ts +269 -0
- package/src/__tests__/identity-lock.test.ts +46 -0
- package/src/__tests__/instrumentation.test.ts +68 -0
- package/src/__tests__/lighting-multi.test.ts +68 -0
- package/src/__tests__/location-convergence-image.test.ts +124 -0
- package/src/__tests__/mention-lock-flag.test.ts +499 -0
- package/src/__tests__/multi-picker-spec.test.ts +71 -0
- package/src/__tests__/music-genre.test.ts +103 -0
- package/src/__tests__/music-mood.test.ts +87 -0
- package/src/__tests__/object-creature-convergence-image.test.ts +105 -0
- package/src/__tests__/parameter-prompt-hint.test.ts +270 -0
- package/src/__tests__/parameter-registry-sync.test.ts +243 -0
- package/src/__tests__/person-age.test.ts +80 -0
- package/src/__tests__/person-analyzer-invariants.test.ts +47 -0
- package/src/__tests__/person-body-axes.test.ts +67 -0
- package/src/__tests__/person-facial-geometry.test.ts +156 -0
- package/src/__tests__/person-regional-aesthetic.test.ts +161 -0
- package/src/__tests__/person-sections.test.ts +18 -0
- package/src/__tests__/picker-analyzer-registry.test.ts +108 -0
- package/src/__tests__/picker-catalogs-project.test.ts +85 -0
- package/src/__tests__/picker-catalogs.test.ts +53 -0
- package/src/__tests__/picker-limits.test.ts +20 -0
- package/src/__tests__/prompt-builder-segments.test.ts +183 -0
- package/src/__tests__/prompt-builder-structured-fields.test.ts +40 -0
- package/src/__tests__/prompt-builder.test.ts +1773 -0
- package/src/__tests__/prompt-wizard-categories.test.ts +31 -0
- package/src/__tests__/provider-prompt-doctrine.test.ts +49 -0
- package/src/__tests__/resolve-prompt-append.test.ts +58 -0
- package/src/__tests__/resolve-prompt.test.ts +44 -0
- package/src/__tests__/role-picker-shared.test.ts +208 -0
- package/src/__tests__/seedance-2-inputs.test.ts +173 -0
- package/src/__tests__/seedance-extend.test.ts +44 -0
- package/src/__tests__/sound-aggregator.test.ts +350 -0
- package/src/__tests__/style-presets.test.ts +34 -0
- package/src/__tests__/temporal-multi.test.ts +74 -0
- package/src/__tests__/transitions.test.ts +213 -0
- package/src/__tests__/video-reference-features.test.ts +41 -0
- package/src/__tests__/video-reference-leading-refs.test.ts +90 -0
- package/src/__tests__/video-reference-resolver.test.ts +233 -0
- package/src/__tests__/video-reference-roles.test.ts +96 -0
- package/src/__tests__/voice-character.test.ts +48 -0
- package/src/__tests__/voice-delivery.test.ts +41 -0
- package/src/__tests__/wardrobe.test.ts +25 -0
- package/src/action-fx.ts +255 -0
- package/src/aesthetic.ts +435 -0
- package/src/assemble-image-input.ts +236 -0
- package/src/assemble-suno-input.ts +147 -0
- package/src/atmosphere.ts +104 -0
- package/src/backdrop.ts +131 -0
- package/src/brand-tokens.ts +154 -0
- package/src/camera-format.ts +76 -0
- package/src/camera-motions.ts +615 -0
- package/src/character-fx.ts +274 -0
- package/src/color-look.ts +103 -0
- package/src/composition-effects.ts +67 -0
- package/src/entity-prompts.ts +231 -0
- package/src/era.ts +292 -0
- package/src/exposure-settings.ts +142 -0
- package/src/factory-presets/generate-image.ts +1644 -0
- package/src/factory-presets/generate-video.ts +1116 -0
- package/src/factory-presets/index.ts +46 -0
- package/src/factory-presets/lottie-overlay.ts +166 -0
- package/src/factory-presets/motion-graphics.ts +350 -0
- package/src/factory-presets/music.ts +734 -0
- package/src/factory-presets/sfx.ts +136 -0
- package/src/factory-presets/shared-image.ts +207 -0
- package/src/factory-presets/switchx.ts +65 -0
- package/src/factory-presets/text.ts +292 -0
- package/src/factory-presets/types.ts +51 -0
- package/src/factory-presets/video-edit.ts +136 -0
- package/src/factory-presets/voice.ts +172 -0
- package/src/factory-presets.ts +2 -0
- package/src/factory-snippets/catalog.ts +105 -0
- package/src/factory-snippets/index.ts +16 -0
- package/src/factory-snippets/types.ts +33 -0
- package/src/framing.ts +634 -0
- package/src/held-prop.ts +187 -0
- package/src/identity-lock.ts +213 -0
- package/src/index.ts +66 -0
- package/src/instrumentation.ts +337 -0
- package/src/lens.ts +59 -0
- package/src/lighting.ts +229 -0
- package/src/loop-subject.ts +276 -0
- package/src/materials.ts +184 -0
- package/src/mood.ts +186 -0
- package/src/music-genre.ts +662 -0
- package/src/music-mood.ts +137 -0
- package/src/object-asset-presets.ts +81 -0
- package/src/parameter-prompt-hint.ts +281 -0
- package/src/person.ts +1368 -0
- package/src/photo-genre.ts +151 -0
- package/src/photographer.ts +612 -0
- package/src/picker-analyzer-registry.ts +374 -0
- package/src/picker-catalogs.ts +858 -0
- package/src/pose.ts +246 -0
- package/src/post-process-effects.ts +94 -0
- package/src/prompt-builder-structured-fields.ts +116 -0
- package/src/prompt-builder.ts +2964 -0
- package/src/prompt-templates.ts +50 -0
- package/src/prompt-wizard-categories.ts +334 -0
- package/src/provider-prompt-doctrine.ts +85 -0
- package/src/render-quality.ts +89 -0
- package/src/resolve-prompt.ts +120 -0
- package/src/seedance-2-inputs.ts +100 -0
- package/src/setting.ts +130 -0
- package/src/sound-aggregator.ts +241 -0
- package/src/style-presets.ts +162 -0
- package/src/style.ts +99 -0
- package/src/styling.ts +585 -0
- package/src/temporal.ts +151 -0
- package/src/transitions.ts +333 -0
- package/src/video-reference-resolver.ts +823 -0
- package/src/voice-character.ts +237 -0
- package/src/voice-delivery.ts +142 -0
- package/src/wardrobe.ts +179 -0
|
@@ -0,0 +1,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
|
+
}
|