@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,53 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { LENSES } from "../lens.js"
3
+ import { MATERIALS } from "../materials.js"
4
+ import { MOODS } from "../mood.js"
5
+
6
+ /**
7
+ * Cinematography catalog gap-fill (Seedance doctrine sources): probe + CCTV
8
+ * lenses, subsurface material, relieved mood, and physical-externalization
9
+ * enrichment of the core emotion hints (official BytePlus table: emotions as
10
+ * externalized physical detail, never abstract words).
11
+ *
12
+ * The locale matrix below guards that the NEW ids ship with all 11 locale
13
+ * sidecars — the global completeness check only verifies files exist, not
14
+ * per-entry coverage, so without this a new entry silently falls back to
15
+ * English in 11 locales.
16
+ */
17
+ const LOCALES = ["ar", "de", "es", "fr", "he", "hi", "ja", "ko", "pt-BR", "ru", "zh-CN"] as const
18
+ const NEW_IDS: Record<string, string[]> = {
19
+ lens: ["probe", "cctv"],
20
+ materials: ["subsurface"],
21
+ mood: ["relieved"],
22
+ }
23
+
24
+ describe("catalog gap-fill entries", () => {
25
+ it("English catalogs carry the new entries with prompt hints", () => {
26
+ for (const id of NEW_IDS.lens!) {
27
+ const e = LENSES.find((l) => l.id === id)
28
+ expect(e, id).toBeDefined()
29
+ expect(e!.promptHint.length).toBeGreaterThan(20)
30
+ }
31
+ expect(MATERIALS.find((m) => m.id === "subsurface")?.promptHint).toContain("subsurface scattering")
32
+ expect(MOODS.find((m) => m.id === "relieved")?.promptHint).toContain("long breath")
33
+ })
34
+
35
+ it("core emotion hints carry physical externalization (doctrine table)", () => {
36
+ const hint = (id: string) => MOODS.find((m) => m.id === id)?.promptHint ?? ""
37
+ expect(hint("sad")).toContain("shoulders trembling")
38
+ expect(hint("joyful")).toContain("corners of the mouth")
39
+ expect(hint("angry")).toContain("fists clenched")
40
+ expect(hint("anxious")).toContain("eyes darting")
41
+ })
42
+
43
+ it.each(LOCALES)("locale %s has all new ids in its sidecars", async (locale) => {
44
+ for (const [catalog, ids] of Object.entries(NEW_IDS)) {
45
+ const mod = (await import(`../../../shared/src/i18n/${catalog}.${locale}.ts`)) as {
46
+ default: Record<string, { label?: string }>
47
+ }
48
+ for (const id of ids) {
49
+ expect(mod.default[id]?.label, `${catalog}.${locale}: ${id}`).toBeTruthy()
50
+ }
51
+ }
52
+ })
53
+ })
@@ -0,0 +1,217 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildImagePrompt } from "../prompt-builder.js"
3
+ import { expandExtraRefsToConnectedReferences } from "@nodaro/shared"
4
+ import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
5
+ import type { ConnectedReference } from "@nodaro/shared"
6
+
7
+ const victoria: ConnectedReference = {
8
+ id: "v", defaultName: "Victoria Hayes", source: "wired-character",
9
+ url: "https://cdn/victoria.png", characterSlug: "victoria-hayes",
10
+ }
11
+
12
+ describe("character reference converges onto the image hybrid form", () => {
13
+ it("@-mention with a 'face' role → 'the face from reference image A', no identity-lock by default, no legacy block", () => {
14
+ const out = buildImagePrompt({
15
+ provider: "nano-banana-pro",
16
+ prompt: "@victoria-hayes:1:face standing on a rooftop",
17
+ connectedReferences: [victoria],
18
+ referenceFormat: "hybrid",
19
+ })
20
+ expect(out.prompt).toContain("the face from reference image A standing on a rooftop")
21
+ expect(out.prompt).not.toContain("Lock the exact identity")
22
+ expect(out.prompt).not.toContain("Use these characters:")
23
+ expect(out.referenceImageUrls).toContain("https://cdn/victoria.png")
24
+ })
25
+
26
+ it("identity-lock OFF → phrase only, no lock line", () => {
27
+ const out = buildImagePrompt({
28
+ provider: "nano-banana-pro",
29
+ prompt: "@victoria-hayes:1:person on a beach",
30
+ connectedReferences: [{ ...victoria, identityLock: { enabled: false } }],
31
+ referenceFormat: "hybrid",
32
+ })
33
+ expect(out.prompt).toContain("the person from reference image A on a beach")
34
+ expect(out.prompt).not.toContain("Lock the exact identity")
35
+ expect(out.prompt).not.toContain("Use these characters:")
36
+ })
37
+
38
+ it("elementInjection (held-prop/styling) survives as a scene directive", () => {
39
+ const out = buildImagePrompt({
40
+ provider: "nano-banana-pro",
41
+ prompt: "@victoria-hayes:1:person in a cafe",
42
+ connectedReferences: [{ ...victoria, elementInjection: "holding a smartphone" }],
43
+ referenceFormat: "hybrid",
44
+ })
45
+ expect(out.prompt).toContain("the person from reference image A in a cafe")
46
+ expect(out.prompt.toLowerCase()).toContain("holding a smartphone")
47
+ expect(out.prompt).not.toContain("Use these characters:")
48
+ })
49
+
50
+ it("identity-lock ON (enabled:true) → phrase + lock line", () => {
51
+ const out = buildImagePrompt({
52
+ provider: "nano-banana-pro",
53
+ prompt: "@victoria-hayes:1:person standing on a rooftop",
54
+ connectedReferences: [{ ...victoria, identityLock: { enabled: true } }],
55
+ referenceFormat: "hybrid",
56
+ })
57
+ expect(out.prompt).toContain("the person from reference image A")
58
+ expect(out.prompt).toContain("Lock the exact identity of the person in reference image A")
59
+ })
60
+
61
+ it("unmentioned wired character → canonical hybrid phrase, no auto-lock (default off), no legacy block", () => {
62
+ const out = buildImagePrompt({
63
+ prompt: "a cinematic portrait",
64
+ connectedReferences: [victoria],
65
+ provider: "nano-banana-pro",
66
+ referenceFormat: "hybrid",
67
+ })
68
+ expect(out.prompt).toContain("the person from reference image A")
69
+ // identity-lock is opt-in (default OFF) — no lock line unless the ref enables it:
70
+ expect(out.prompt).not.toContain("Lock the exact identity")
71
+ expect(out.prompt).not.toContain("Use these characters:")
72
+ expect(out.referenceImageUrls).toContain("https://cdn/victoria.png")
73
+ })
74
+
75
+ it("unmentioned wired character WITH elementInjection → canonical phrase + elementInjection survives (must NOT be silently dropped), no legacy block", () => {
76
+ const out = buildImagePrompt({
77
+ prompt: "a cinematic portrait",
78
+ connectedReferences: [{ ...victoria, elementInjection: "holding a vintage camera" }],
79
+ provider: "nano-banana-pro",
80
+ referenceFormat: "hybrid",
81
+ })
82
+ expect(out.prompt).toContain("the person from reference image A")
83
+ expect(out.prompt.toLowerCase()).toContain("vintage camera")
84
+ expect(out.prompt).not.toContain("Use these characters:")
85
+ })
86
+
87
+ it("hybrid extra-ref attaches its URL and surfaces its description (no regression, no legacy block)", () => {
88
+ const extra: ConnectedReference = {
89
+ id: "x", defaultName: "Prop", source: "manual",
90
+ url: "https://cdn/prop.png", description: "a brass lantern", isExtraRef: true,
91
+ }
92
+ const out = buildImagePrompt({
93
+ prompt: "a still life",
94
+ connectedReferences: [extra],
95
+ provider: "nano-banana-pro",
96
+ referenceFormat: "hybrid",
97
+ })
98
+ expect(out.referenceImageUrls).toContain("https://cdn/prop.png")
99
+ expect(out.prompt.toLowerCase()).toContain("brass lantern")
100
+ expect(out.prompt).not.toContain("Use these characters:")
101
+ })
102
+
103
+ it("first-sight extra-ref character WITH elementInjection → injection survives as a scene directive (must NOT be silently dropped), no legacy block", () => {
104
+ // A character that appears ONLY as a first-sight extra-ref (not @-mentioned,
105
+ // not a canonical fallback). `variantSlug` makes it a picked-variant extra,
106
+ // which is what excludes it from `selectCanonicalFallbackRefs` (`if
107
+ // (r.variantSlug) continue`) — so `renderExtraRefsHybrid`'s first-sight
108
+ // branch is the ONLY surface for its `elementInjection`. A wired-character
109
+ // extra-ref WITHOUT a variantSlug would also be auto-attached by the
110
+ // canonical fallback (which already surfaces the injection), so it would not
111
+ // exercise — nor genuinely guard — the extra-ref hole this covers.
112
+ const kato: ConnectedReference = {
113
+ id: "k", defaultName: "Kato", source: "wired-character",
114
+ url: "https://cdn/kato.png", characterSlug: "kato", variantSlug: "alt",
115
+ isExtraRef: true, elementInjection: "holding a katana",
116
+ description: "in a dark alley",
117
+ }
118
+ const out = buildImagePrompt({
119
+ provider: "nano-banana-pro",
120
+ prompt: "a cinematic portrait",
121
+ connectedReferences: [kato],
122
+ referenceFormat: "hybrid",
123
+ })
124
+ expect(out.prompt.toLowerCase()).toContain("katana")
125
+ expect(out.referenceImageUrls).toContain("https://cdn/kato.png")
126
+ expect(out.prompt).not.toContain("Use these characters:")
127
+ })
128
+
129
+ it("first-sight extra-ref carrying a role (usageMode) → phrase uses THAT role, not the source default (Reference Roles F3)", () => {
130
+ // A first-sight character extra whose per-ref usage-mode override ("style",
131
+ // folded into `defaultUsageMode` by `expandExtraRefsToConnectedReferences`)
132
+ // is a curated preset → the hybrid phrase must read "the style from …",
133
+ // aligned UP to the video extras first-sight formula. Before F3 the image
134
+ // side ALWAYS emitted the source default ("the person from …").
135
+ const stylist: ConnectedReference = {
136
+ id: "s", defaultName: "Kira / look", source: "wired-character",
137
+ url: "https://cdn/kira-look.png", characterSlug: "kira", variantSlug: "look",
138
+ defaultUsageMode: "style", isExtraRef: true,
139
+ }
140
+ const out = buildImagePrompt({
141
+ provider: "nano-banana-pro",
142
+ prompt: "a portrait",
143
+ connectedReferences: [stylist],
144
+ referenceFormat: "hybrid",
145
+ })
146
+ expect(out.prompt).toContain("the style from reference image A")
147
+ expect(out.prompt).not.toContain("the person from reference image A")
148
+ expect(out.prompt).not.toContain("Use these characters:")
149
+ })
150
+
151
+ it("first-sight extra-ref with NO role → source default phrase (unchanged)", () => {
152
+ // No usage-mode override + a non-preset variant ("alt") → the source default
153
+ // ("person"), byte-identical to the pre-F3 behavior for role-less extras.
154
+ const plain: ConnectedReference = {
155
+ id: "p", defaultName: "Kira / alt", source: "wired-character",
156
+ url: "https://cdn/kira-alt.png", characterSlug: "kira", variantSlug: "alt",
157
+ isExtraRef: true,
158
+ }
159
+ const out = buildImagePrompt({
160
+ provider: "nano-banana-pro",
161
+ prompt: "a portrait",
162
+ connectedReferences: [plain],
163
+ referenceFormat: "hybrid",
164
+ })
165
+ expect(out.prompt).toContain("the person from reference image A")
166
+ expect(out.prompt).not.toContain("Use these characters:")
167
+ })
168
+
169
+ it("INVARIANT: image and video extras roles CONVERGE — both honor the coalesced char-node default via their REAL pipelines (Reference Roles deferred-follow-up)", () => {
170
+ // Build the extra the way PRODUCTION does — a raw `ExtraRefInput` with NO
171
+ // per-ref usageMode override — and run it through the REAL pipelines on BOTH
172
+ // sides (not hand-built ConnectedReferences or a mirrored formula). The char
173
+ // node's default mode is "style"; the extra carries no per-ref override and a
174
+ // non-preset picked variant ("look").
175
+ const CHAR_DEFAULT = "style" as const
176
+
177
+ // IMAGE side: `expandExtraRefsToConnectedReferences` coalesces `usageMode` →
178
+ // char-node default → "identical" into `defaultUsageMode` (always defined for
179
+ // a char extra — which is why the image call needs no `?? variantSlug`).
180
+ const expanded = expandExtraRefsToConnectedReferences(
181
+ [{ url: "https://cdn/kira-look.png", characterSlug: "kira", variantSlug: "look" }],
182
+ (slug) => (slug === "kira" ? { defaultUsageMode: CHAR_DEFAULT, displayName: "Kira" } : undefined),
183
+ )
184
+ expect(expanded).toHaveLength(1)
185
+ expect(expanded[0].defaultUsageMode).toBe(CHAR_DEFAULT)
186
+ const imageOut = buildImagePrompt({
187
+ provider: "nano-banana-pro",
188
+ prompt: "a portrait",
189
+ connectedReferences: expanded,
190
+ referenceFormat: "hybrid",
191
+ })
192
+ expect(imageOut.prompt).toContain("the style from reference image A")
193
+ expect(imageOut.prompt).not.toContain("the person from reference image A")
194
+
195
+ // VIDEO side: the resolver computes the SAME coalesced `effectiveMode`
196
+ // (`ex.usageMode` → `lookupCharacterBySlug().defaultUsageMode` → "identical")
197
+ // and now feeds THAT to `firstSightExtraRole`. Same raw extra (no per-ref
198
+ // override) + same char default → the video first-sight role must ALSO be
199
+ // "style". This proves convergence through the real video code path, not a
200
+ // re-derived formula.
201
+ const videoOut = resolveVideoReferenceCore({
202
+ prompt: "a portrait",
203
+ wiredCharRefs: [],
204
+ extraRefs: [{ url: "https://cdn/kira-look.png", characterSlug: "kira", variantSlug: "look" }],
205
+ lookupCharacterBySlug: () => ({ characterName: "Kira", defaultUsageMode: CHAR_DEFAULT }),
206
+ hybridRoles: true,
207
+ })
208
+ expect(videoOut.prompt).toContain("the style from @image_1")
209
+ expect(videoOut.prompt).not.toContain("the person from @image_1")
210
+
211
+ // CONVERGENCE: the SAME logical extra yields the SAME role on both surfaces.
212
+ expect(imageOut.prompt).toContain("the style from reference image A")
213
+ expect(videoOut.prompt).toContain("the style from @image_1")
214
+ expect(imageOut.prompt).not.toContain("Use these characters:")
215
+ expect(videoOut.prompt).not.toContain("Use these characters:")
216
+ })
217
+ })
@@ -0,0 +1,167 @@
1
+ // packages/shared/src/__tests__/character-default-role-image.test.ts
2
+ //
3
+ // Character node `defaultRole` (the hybrid role-dropdown pick) must be honored
4
+ // by ALL THREE image resolution paths — canonical fallback (wired, unmentioned),
5
+ // @-mention (un-roled token), and extras (first-sight) — with the precedence:
6
+ // per-mention token role → node defaultRole → defaultUsageMode-derived → "person".
7
+ // Legacy format ignores `defaultRole` entirely (byte-identical guard).
8
+ import { describe, it, expect } from "vitest"
9
+ import { buildImagePrompt } from "../prompt-builder.js"
10
+ import type { ConnectedReference } from "@nodaro/shared"
11
+
12
+ const kira: ConnectedReference = {
13
+ id: "k", defaultName: "Kira", source: "wired-character",
14
+ url: "https://cdn/kira.png", characterSlug: "kira",
15
+ }
16
+
17
+ describe("image canonical fallback honors the node defaultRole", () => {
18
+ it("unmentioned wired character with defaultRole 'clothes' → 'the clothes from reference image A'", () => {
19
+ const out = buildImagePrompt({
20
+ provider: "nano-banana-pro",
21
+ prompt: "a cinematic portrait",
22
+ connectedReferences: [{ ...kira, defaultRole: "clothes" }],
23
+ referenceFormat: "hybrid",
24
+ })
25
+ expect(out.prompt).toContain("the clothes from reference image A")
26
+ expect(out.prompt).not.toContain("the person from reference image A")
27
+ })
28
+
29
+ it("unmentioned wired character with defaultUsageMode 'style' (no defaultRole) → 'the style from …' (node default finally honored)", () => {
30
+ const out = buildImagePrompt({
31
+ provider: "nano-banana-pro",
32
+ prompt: "a cinematic portrait",
33
+ connectedReferences: [{ ...kira, defaultUsageMode: "style" }],
34
+ referenceFormat: "hybrid",
35
+ })
36
+ expect(out.prompt).toContain("the style from reference image A")
37
+ expect(out.prompt).not.toContain("the person from reference image A")
38
+ })
39
+
40
+ it("plain unmentioned wired character (neither field) → 'the person from …' (unchanged)", () => {
41
+ const out = buildImagePrompt({
42
+ provider: "nano-banana-pro",
43
+ prompt: "a cinematic portrait",
44
+ connectedReferences: [kira],
45
+ referenceFormat: "hybrid",
46
+ })
47
+ expect(out.prompt).toContain("the person from reference image A")
48
+ })
49
+
50
+ it("a Custom defaultRole survives verbatim on the canonical path", () => {
51
+ const out = buildImagePrompt({
52
+ provider: "nano-banana-pro",
53
+ prompt: "a cinematic portrait",
54
+ connectedReferences: [{ ...kira, defaultRole: "earrings" }],
55
+ referenceFormat: "hybrid",
56
+ })
57
+ expect(out.prompt).toContain("the earrings from reference image A")
58
+ })
59
+ })
60
+
61
+ describe("image @-mention honors the node defaultRole for un-roled tokens", () => {
62
+ it("un-roled '@kira:1' with defaultRole 'hair' → 'the hair from reference image A'", () => {
63
+ const out = buildImagePrompt({
64
+ provider: "nano-banana-pro",
65
+ prompt: "@kira:1 walking in the rain",
66
+ connectedReferences: [{ ...kira, defaultRole: "hair" }],
67
+ referenceFormat: "hybrid",
68
+ })
69
+ expect(out.prompt).toContain("the hair from reference image A walking in the rain")
70
+ })
71
+
72
+ it("a per-mention token role still overrides the node defaultRole", () => {
73
+ const out = buildImagePrompt({
74
+ provider: "nano-banana-pro",
75
+ prompt: "@kira:1:face walking in the rain",
76
+ connectedReferences: [{ ...kira, defaultRole: "hair" }],
77
+ referenceFormat: "hybrid",
78
+ })
79
+ expect(out.prompt).toContain("the face from reference image A walking in the rain")
80
+ expect(out.prompt).not.toContain("the hair from")
81
+ })
82
+
83
+ it("un-roled '@kira:1' with only defaultUsageMode 'face' → 'the face from …' (derived fallback)", () => {
84
+ const out = buildImagePrompt({
85
+ provider: "nano-banana-pro",
86
+ prompt: "@kira:1 at dawn",
87
+ connectedReferences: [{ ...kira, defaultUsageMode: "face" }],
88
+ referenceFormat: "hybrid",
89
+ })
90
+ expect(out.prompt).toContain("the face from reference image A at dawn")
91
+ })
92
+
93
+ it("un-roled '@kira:1' with defaultUsageMode 'identical' → 'the person from …' (non-preset collapses)", () => {
94
+ const out = buildImagePrompt({
95
+ provider: "nano-banana-pro",
96
+ prompt: "@kira:1 at dawn",
97
+ connectedReferences: [{ ...kira, defaultUsageMode: "identical" }],
98
+ referenceFormat: "hybrid",
99
+ })
100
+ expect(out.prompt).toContain("the person from reference image A at dawn")
101
+ })
102
+ })
103
+
104
+ describe("image extras honor the node defaultRole (first-sight)", () => {
105
+ it("first-sight character extra with defaultRole 'hair' → 'the hair from reference image A'", () => {
106
+ // variantSlug makes it a picked-variant extra → excluded from the canonical
107
+ // fallback, so renderExtraRefsHybrid's first-sight branch is the only surface.
108
+ const extra: ConnectedReference = {
109
+ ...kira, id: "x", variantSlug: "look", isExtraRef: true, defaultRole: "hair",
110
+ }
111
+ const out = buildImagePrompt({
112
+ provider: "nano-banana-pro",
113
+ prompt: "a portrait",
114
+ connectedReferences: [extra],
115
+ referenceFormat: "hybrid",
116
+ })
117
+ expect(out.prompt).toContain("the hair from reference image A")
118
+ expect(out.prompt).not.toContain("the person from reference image A")
119
+ })
120
+
121
+ it("extra with defaultUsageMode 'style' and NO defaultRole keeps the derived role (unchanged)", () => {
122
+ const extra: ConnectedReference = {
123
+ ...kira, id: "x", variantSlug: "look", isExtraRef: true, defaultUsageMode: "style",
124
+ }
125
+ const out = buildImagePrompt({
126
+ provider: "nano-banana-pro",
127
+ prompt: "a portrait",
128
+ connectedReferences: [extra],
129
+ referenceFormat: "hybrid",
130
+ })
131
+ expect(out.prompt).toContain("the style from reference image A")
132
+ })
133
+ })
134
+
135
+ describe("lock-line dedup (same reference locked via two paths)", () => {
136
+ it("an extra whose URL equals the wired canonical emits the lock line ONCE", () => {
137
+ // The pair-back letter gate routes a same-URL extra into the first-sight
138
+ // branch (same letter), which would re-emit an identical {ref}-bound lock
139
+ // line — the assembler's Set-dedup must collapse it to one.
140
+ const lock = { enabled: true, text: "Preserve the overall facial likeness of the subject in {ref}." }
141
+ const canonical: ConnectedReference = { ...kira, identityLock: lock }
142
+ const sameUrlExtra: ConnectedReference = {
143
+ ...kira, id: "x", variantSlug: "look", isExtraRef: true, identityLock: lock,
144
+ }
145
+ const out = buildImagePrompt({
146
+ provider: "nano-banana-pro",
147
+ prompt: "a cinematic portrait",
148
+ connectedReferences: [canonical, sameUrlExtra],
149
+ referenceFormat: "hybrid",
150
+ })
151
+ const occurrences = out.prompt.split("overall facial likeness of the subject in reference image A").length - 1
152
+ expect(occurrences).toBe(1)
153
+ })
154
+ })
155
+
156
+ describe("legacy format ignores defaultRole (byte-identical guard)", () => {
157
+ it("legacy assembly with a defaultRole-carrying ref emits the legacy block, no role phrase", () => {
158
+ const out = buildImagePrompt({
159
+ provider: "nano-banana-pro",
160
+ prompt: "a cinematic portrait",
161
+ connectedReferences: [{ ...kira, defaultRole: "clothes" }],
162
+ referenceFormat: "legacy",
163
+ })
164
+ expect(out.prompt).not.toContain("the clothes from reference image A")
165
+ expect(out.prompt).toContain("Use these characters:")
166
+ })
167
+ })
@@ -0,0 +1,168 @@
1
+ // packages/shared/src/__tests__/character-default-role-video.test.ts
2
+ //
3
+ // Character node `defaultRole` + mapped `identityLock` must be honored by ALL
4
+ // THREE video resolution paths — canonical fallback, @-mention (un-roled
5
+ // token), and extras (first-sight via `CharacterMeta`) — mirroring the image
6
+ // side (character-default-role-image.test.ts) so the surfaces stay converged.
7
+ import { describe, it, expect } from "vitest"
8
+ import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
9
+ import { characterLockToRefLock } from "../identity-lock.js"
10
+ import { buildImagePrompt } from "../prompt-builder.js"
11
+ import type { ConnectedReference } from "@nodaro/shared"
12
+
13
+ const kira = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
14
+ id: "k", defaultName: "Kira", source: "wired-character", url: "u-kira", characterSlug: "kira", ...over,
15
+ })
16
+
17
+ describe("video canonical fallback honors the node defaultRole", () => {
18
+ it("unmentioned wired character with defaultRole 'clothes' → 'the clothes from @image_1'", () => {
19
+ const out = resolveVideoReferenceCore({
20
+ prompt: "a slow dolly", wiredCharRefs: [kira({ defaultRole: "clothes" })], hybridRoles: true,
21
+ })
22
+ expect(out.prompt).toContain("the clothes from @image_1")
23
+ expect(out.prompt).not.toContain("the person from @image_1")
24
+ })
25
+
26
+ it("unmentioned wired character with defaultUsageMode 'style' (no defaultRole) → 'the style from @image_1'", () => {
27
+ const out = resolveVideoReferenceCore({
28
+ prompt: "a slow dolly", wiredCharRefs: [kira({ defaultUsageMode: "style" })], hybridRoles: true,
29
+ })
30
+ expect(out.prompt).toContain("the style from @image_1")
31
+ expect(out.prompt).not.toContain("the person from @image_1")
32
+ })
33
+
34
+ it("plain unmentioned wired character → 'the person from @image_1' (unchanged)", () => {
35
+ const out = resolveVideoReferenceCore({
36
+ prompt: "a slow dolly", wiredCharRefs: [kira()], hybridRoles: true,
37
+ })
38
+ expect(out.prompt).toContain("the person from @image_1")
39
+ })
40
+ })
41
+
42
+ describe("video @-mention honors the node defaultRole for un-roled tokens", () => {
43
+ it("un-roled '@kira:1' with defaultRole 'hair' → 'the hair from @image_1'", () => {
44
+ const out = resolveVideoReferenceCore({
45
+ prompt: "@kira:1 walks in", wiredCharRefs: [kira({ defaultRole: "hair" })], hybridRoles: true,
46
+ })
47
+ expect(out.prompt).toContain("the hair from @image_1 walks in")
48
+ })
49
+
50
+ it("a per-mention token role still overrides the node defaultRole", () => {
51
+ const out = resolveVideoReferenceCore({
52
+ prompt: "@kira:1:face walks in", wiredCharRefs: [kira({ defaultRole: "hair" })], hybridRoles: true,
53
+ })
54
+ expect(out.prompt).toContain("the face from @image_1 walks in")
55
+ expect(out.prompt).not.toContain("the hair from")
56
+ })
57
+ })
58
+
59
+ describe("video extras honor the node defaultRole + mapped identityLock (CharacterMeta)", () => {
60
+ it("first-sight extra with meta.defaultRole 'hair' and no per-ref override → 'the hair from @image_1'", () => {
61
+ const out = resolveVideoReferenceCore({
62
+ prompt: "a portrait",
63
+ wiredCharRefs: [],
64
+ extraRefs: [{ url: "u-look", characterSlug: "kira", variantSlug: "look" }],
65
+ lookupCharacterBySlug: () => ({ characterName: "Kira", defaultRole: "hair" }),
66
+ hybridRoles: true,
67
+ })
68
+ expect(out.prompt).toContain("the hair from @image_1")
69
+ expect(out.prompt).not.toContain("the person from @image_1")
70
+ })
71
+
72
+ it("a per-extra usageMode override suppresses meta.defaultRole", () => {
73
+ const out = resolveVideoReferenceCore({
74
+ prompt: "a portrait",
75
+ wiredCharRefs: [],
76
+ extraRefs: [{ url: "u-look", characterSlug: "kira", variantSlug: "look", usageMode: "style" }],
77
+ lookupCharacterBySlug: () => ({ characterName: "Kira", defaultRole: "hair" }),
78
+ hybridRoles: true,
79
+ })
80
+ expect(out.prompt).toContain("the style from @image_1")
81
+ expect(out.prompt).not.toContain("the hair from")
82
+ })
83
+
84
+ it("meta.identityLock (node soft mapping) emits the lock line for a first-sight extra; per-extra lock wins when both set", () => {
85
+ const softLock = characterLockToRefLock("soft")
86
+ const out = resolveVideoReferenceCore({
87
+ prompt: "a portrait",
88
+ wiredCharRefs: [],
89
+ extraRefs: [{ url: "u-look", characterSlug: "kira", variantSlug: "look" }],
90
+ lookupCharacterBySlug: () => ({ characterName: "Kira", identityLock: softLock }),
91
+ hybridRoles: true,
92
+ })
93
+ // The node's soft lock is {ref}-bound at emission (binding = @image_1).
94
+ expect(out.prompt).toContain("overall facial likeness of the subject in @image_1")
95
+
96
+ const perExtraOff = resolveVideoReferenceCore({
97
+ prompt: "a portrait",
98
+ wiredCharRefs: [],
99
+ extraRefs: [{
100
+ url: "u-look", characterSlug: "kira", variantSlug: "look",
101
+ identityLock: { enabled: false },
102
+ }],
103
+ lookupCharacterBySlug: () => ({ characterName: "Kira", identityLock: softLock }),
104
+ hybridRoles: true,
105
+ })
106
+ expect(perExtraOff.prompt).not.toContain("overall facial likeness")
107
+ })
108
+ })
109
+
110
+ describe("route-shaped extras (generate-video / text-to-video / MCP adapter contract)", () => {
111
+ it("a per-extra defaultRole wins even when usageMode carries the COALESCED mode (the route adapter shape)", () => {
112
+ // The direct-API adapter can only feed the coalesced defaultUsageMode as
113
+ // `usageMode` (always set for character extras) — which must NOT suppress
114
+ // the node default when it rides the dedicated per-extra `defaultRole` slot.
115
+ const out = resolveVideoReferenceCore({
116
+ prompt: "a portrait",
117
+ wiredCharRefs: [],
118
+ extraRefs: [{
119
+ url: "u-look", characterSlug: "kira", variantSlug: "look",
120
+ usageMode: "identical", defaultRole: "hair",
121
+ }],
122
+ hybridRoles: true,
123
+ })
124
+ expect(out.prompt).toContain("the hair from @image_1")
125
+ expect(out.prompt).not.toContain("the person from @image_1")
126
+ })
127
+
128
+ it("a per-extra identityLock (route CR passthrough) emits the lock line without any meta", () => {
129
+ const out = resolveVideoReferenceCore({
130
+ prompt: "a portrait",
131
+ wiredCharRefs: [],
132
+ extraRefs: [{
133
+ url: "u-look", characterSlug: "kira", variantSlug: "look",
134
+ usageMode: "identical",
135
+ identityLock: characterLockToRefLock("strict"),
136
+ }],
137
+ hybridRoles: true,
138
+ })
139
+ expect(out.prompt).toContain("The facial identity of the subject in @image_1 must match exactly")
140
+ })
141
+ })
142
+
143
+ describe("image/video parity for a defaultRole node (convergence pinned)", () => {
144
+ it("the SAME defaultRole node yields the SAME role phrase on both surfaces", () => {
145
+ const ref = kira({ url: "https://cdn/kira.png", defaultRole: "clothes" })
146
+ const imageOut = buildImagePrompt({
147
+ provider: "nano-banana-pro",
148
+ prompt: "a cinematic portrait",
149
+ connectedReferences: [ref],
150
+ referenceFormat: "hybrid",
151
+ })
152
+ const videoOut = resolveVideoReferenceCore({
153
+ prompt: "a cinematic portrait", wiredCharRefs: [ref], hybridRoles: true,
154
+ })
155
+ expect(imageOut.prompt).toContain("the clothes from reference image A")
156
+ expect(videoOut.prompt).toContain("the clothes from @image_1")
157
+ })
158
+ })
159
+
160
+ describe("video legacy ignores defaultRole (byte-identical guard)", () => {
161
+ it("legacy (hybridRoles: false) with a defaultRole-carrying ref emits the legacy block, no role phrase", () => {
162
+ const out = resolveVideoReferenceCore({
163
+ prompt: "a slow dolly", wiredCharRefs: [kira({ defaultRole: "clothes" })], hybridRoles: false,
164
+ })
165
+ expect(out.prompt).not.toContain("the clothes from @image_1")
166
+ expect(out.prompt).toContain("Use these characters:")
167
+ })
168
+ })