@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,283 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { assembleSunoInput } from "../assemble-suno-input.js"
3
+ import { composeSoundHintFromConnections, appendField, truncateForField } from "../index.js"
4
+ import type { HintNodeLike, HintEdgeLike } from "../parameter-prompt-hint.js"
5
+
6
+ /**
7
+ * `assembleSunoInput` is the keystone of the Suno preview==run refactor: the
8
+ * FE run (`execute-node.ts`), the BE run (`payload-builder.ts`), and the editor
9
+ * Final preview all route their `suno-generate` assembly through it. The FE
10
+ * inline block (`execute-node.ts` ~3016–3076) is the designated source of
11
+ * truth; these tests pin that the shared fn reproduces it field-by-field and
12
+ * reconciles the 5 FE↔BE divergences toward the FE.
13
+ *
14
+ * The hint TEXT itself is computed by `composeSoundHintFromConnections` (tested
15
+ * in `sound-aggregator.test.ts`); here we use it (plus `appendField` /
16
+ * `truncateForField`) as ORACLES — the risk these tests guard is the FOLD
17
+ * WIRING (which branch, which budget, skip-when-empty, the normalizations), not
18
+ * the helper internals. Mirrors how `assemble-image-input.test.ts` uses
19
+ * `buildImagePrompt` / `getFramingPromptHint` as oracles.
20
+ */
21
+
22
+ const CONSUMER_ID = "s1"
23
+
24
+ const node = (data: Record<string, unknown>): HintNodeLike => ({
25
+ id: CONSUMER_ID,
26
+ type: "suno-generate",
27
+ data,
28
+ })
29
+
30
+ const emptyGraph: { nodes: HintNodeLike[]; edges: HintEdgeLike[] } = {
31
+ nodes: [],
32
+ edges: [],
33
+ }
34
+
35
+ function audioStyleEdge(sourceId: string): HintEdgeLike {
36
+ return { source: sourceId, target: CONSUMER_ID, sourceHandle: "out", targetHandle: "audio-style" }
37
+ }
38
+
39
+ /** A connected music-genre picker → a non-empty audio-style hint. */
40
+ function graphWithHint(): { nodes: HintNodeLike[]; edges: HintEdgeLike[] } {
41
+ return {
42
+ nodes: [{ id: "g", type: "music-genre", data: { genre: "electronic", subgenre: "synthwave" } }],
43
+ edges: [audioStyleEdge("g")],
44
+ }
45
+ }
46
+
47
+ /** A connected voice-character picker → populates `fields.vocalGender`. */
48
+ function graphWithVocalGender(gender: "male" | "female"): { nodes: HintNodeLike[]; edges: HintEdgeLike[] } {
49
+ return {
50
+ nodes: [{ id: "v", type: "voice-character", data: { gender, timbre: "warm" } }],
51
+ edges: [audioStyleEdge("v")],
52
+ }
53
+ }
54
+
55
+ describe("assembleSunoInput — custom mode (style fold @500)", () => {
56
+ it("custom mode: leaves prompt = userPrompt, keeps user style + model", () => {
57
+ const r = assembleSunoInput({
58
+ node: node({ customMode: true, style: "lo-fi", model: "V5" }),
59
+ graph: emptyGraph,
60
+ userPrompt: "ignored-in-custom",
61
+ })
62
+ expect(r.customMode).toBe(true)
63
+ expect(r.style).toBe("lo-fi")
64
+ expect(r.model).toBe("V5")
65
+ // Custom mode folds the hint into `style`; the prompt is the user prompt verbatim.
66
+ expect(r.prompt).toBe("ignored-in-custom")
67
+ })
68
+
69
+ it("custom + non-empty style + hint: folds hint into style via appendField(truncateForField(@500))", () => {
70
+ const g = graphWithHint()
71
+ const consumer = node({ customMode: true, style: "lo-fi" })
72
+ const hint = composeSoundHintFromConnections(consumer, "suno-generate", g).text
73
+ expect(hint).not.toBe("") // fixture sanity
74
+ const r = assembleSunoInput({ node: consumer, graph: g, userPrompt: "ignored" })
75
+ expect(r.style).toBe(appendField("lo-fi", truncateForField(hint, "lo-fi", 500)))
76
+ expect(r.prompt).toBe("ignored") // prompt untouched in custom mode
77
+ })
78
+
79
+ it("custom + EMPTY style + hint: bare hint, NOT truncated (divergence B — skip-when-empty)", () => {
80
+ const g = graphWithHint()
81
+ const consumer = node({ customMode: true, style: "" })
82
+ const hint = composeSoundHintFromConnections(consumer, "suno-generate", g).text
83
+ const r = assembleSunoInput({ node: consumer, graph: g, userPrompt: "ignored" })
84
+ expect(r.style).toBe(hint) // no appendField / no truncateForField when the user field is empty
85
+ })
86
+ })
87
+
88
+ describe("assembleSunoInput — non-custom mode (prompt fold @3000)", () => {
89
+ it("non-custom + userPrompt + hint: folds hint into prompt via appendField(truncateForField(@3000))", () => {
90
+ const g = graphWithHint()
91
+ const consumer = node({ customMode: false })
92
+ const hint = composeSoundHintFromConnections(consumer, "suno-generate", g).text
93
+ const r = assembleSunoInput({ node: consumer, graph: g, userPrompt: "a happy song" })
94
+ expect(r.prompt).toBe(appendField("a happy song", truncateForField(hint, "a happy song", 3000)))
95
+ expect(r.style).toBeUndefined() // empty user style → undefined
96
+ })
97
+
98
+ it("non-custom: empty style/title normalize to undefined; prompt is the bare user text", () => {
99
+ const r = assembleSunoInput({
100
+ node: node({ customMode: false, style: "", title: "" }),
101
+ graph: emptyGraph,
102
+ userPrompt: "a happy song",
103
+ })
104
+ expect(r.prompt).toBe("a happy song")
105
+ expect(r.style).toBeUndefined()
106
+ expect(r.title).toBeUndefined()
107
+ })
108
+
109
+ it("non-custom + EMPTY userPrompt + hint: bare hint into prompt, NOT truncated (divergence B)", () => {
110
+ const g = graphWithHint()
111
+ const consumer = node({ customMode: false })
112
+ const hint = composeSoundHintFromConnections(consumer, "suno-generate", g).text
113
+ const r = assembleSunoInput({ node: consumer, graph: g, userPrompt: "" })
114
+ expect(r.prompt).toBe(hint)
115
+ })
116
+ })
117
+
118
+ describe("assembleSunoInput — truncation when the hint exceeds the budget", () => {
119
+ it("custom: a long user style shrinks the budget so the hint is truncated to fit 500", () => {
120
+ const g = graphWithHint()
121
+ const longStyle = "a".repeat(495) // budget = 500 - 495 - 2 = 3
122
+ const consumer = node({ customMode: true, style: longStyle })
123
+ const hint = composeSoundHintFromConnections(consumer, "suno-generate", g).text
124
+ expect(hint.length).toBeGreaterThan(3) // fixture sanity — guarantees truncation actually happens
125
+ const truncated = truncateForField(hint, longStyle, 500)
126
+ expect(truncated.length).toBeLessThan(hint.length) // proves it truncated
127
+ const r = assembleSunoInput({ node: consumer, graph: g, userPrompt: "x" })
128
+ expect(r.style).toBe(appendField(longStyle, truncated))
129
+ expect(r.style!.length).toBeLessThanOrEqual(500)
130
+ })
131
+ })
132
+
133
+ describe("assembleSunoInput — vocalGender precedence (divergence D)", () => {
134
+ it("data.vocalGender wins", () => {
135
+ const r = assembleSunoInput({ node: node({ vocalGender: "male" }), graph: emptyGraph, userPrompt: "song" })
136
+ expect(r.vocalGender).toBe("male")
137
+ })
138
+
139
+ it("data.vocalGender = '' falls through to the connected voice-character field", () => {
140
+ const r = assembleSunoInput({
141
+ node: node({ vocalGender: "" }),
142
+ graph: graphWithVocalGender("female"),
143
+ userPrompt: "song",
144
+ })
145
+ expect(r.vocalGender).toBe("female")
146
+ })
147
+
148
+ it("data.vocalGender beats the connected field when both are present", () => {
149
+ const r = assembleSunoInput({
150
+ node: node({ vocalGender: "male" }),
151
+ graph: graphWithVocalGender("female"),
152
+ userPrompt: "song",
153
+ })
154
+ expect(r.vocalGender).toBe("male")
155
+ })
156
+
157
+ it("neither present → undefined", () => {
158
+ const r = assembleSunoInput({ node: node({}), graph: emptyGraph, userPrompt: "song" })
159
+ expect(r.vocalGender).toBeUndefined()
160
+ })
161
+ })
162
+
163
+ describe("assembleSunoInput — persona spread", () => {
164
+ it("spreads personaId + personaModel onto the result", () => {
165
+ const r = assembleSunoInput({
166
+ node: node({}),
167
+ graph: emptyGraph,
168
+ userPrompt: "song",
169
+ persona: { personaId: "p1", personaModel: "style_persona" },
170
+ })
171
+ expect(r.personaId).toBe("p1")
172
+ expect(r.personaModel).toBe("style_persona")
173
+ })
174
+
175
+ it("no persona → both undefined", () => {
176
+ const r = assembleSunoInput({ node: node({}), graph: emptyGraph, userPrompt: "song" })
177
+ expect(r.personaId).toBeUndefined()
178
+ expect(r.personaModel).toBeUndefined()
179
+ })
180
+ })
181
+
182
+ describe("assembleSunoInput — || undefined normalization (divergence E)", () => {
183
+ it("empty model/style/title/negativeStyle normalize to undefined", () => {
184
+ const r = assembleSunoInput({
185
+ node: node({ model: "", style: "", title: "", negativeStyle: "" }),
186
+ graph: emptyGraph,
187
+ userPrompt: "song",
188
+ })
189
+ expect(r.model).toBeUndefined()
190
+ expect(r.style).toBeUndefined()
191
+ expect(r.title).toBeUndefined()
192
+ expect(r.negativeStyle).toBeUndefined()
193
+ })
194
+
195
+ it("passes non-empty model/title/negativeStyle through verbatim", () => {
196
+ const r = assembleSunoInput({
197
+ node: node({ model: "V5", title: "My Song", negativeStyle: "heavy metal" }),
198
+ graph: emptyGraph,
199
+ userPrompt: "song",
200
+ })
201
+ expect(r.model).toBe("V5")
202
+ expect(r.title).toBe("My Song")
203
+ expect(r.negativeStyle).toBe("heavy metal")
204
+ })
205
+ })
206
+
207
+ describe("assembleSunoInput — lyrics is caller-pre-resolved (divergence C)", () => {
208
+ it("lyrics comes from input.lyrics, NOT data.lyrics", () => {
209
+ const r = assembleSunoInput({
210
+ node: node({ lyrics: "from-data-should-be-ignored" }),
211
+ graph: emptyGraph,
212
+ userPrompt: "song",
213
+ })
214
+ // The fn never reads data.lyrics for the VALUE — only the caller's input.lyrics.
215
+ expect(r.lyrics).toBeUndefined()
216
+ // …but custom-mode auto-detect (getEffectiveSunoCustomMode) DOES read data.lyrics.
217
+ expect(r.customMode).toBe(true)
218
+ })
219
+
220
+ it("passes a caller-resolved lyrics string through; empty → undefined", () => {
221
+ expect(
222
+ assembleSunoInput({ node: node({}), graph: emptyGraph, userPrompt: "song", lyrics: "[Verse] hi" }).lyrics,
223
+ ).toBe("[Verse] hi")
224
+ expect(
225
+ assembleSunoInput({ node: node({}), graph: emptyGraph, userPrompt: "song", lyrics: "" }).lyrics,
226
+ ).toBeUndefined()
227
+ })
228
+ })
229
+
230
+ describe("assembleSunoInput — pass-through fields + customMode auto-detect", () => {
231
+ it("instrumental defaults to false and passes true through", () => {
232
+ expect(assembleSunoInput({ node: node({}), graph: emptyGraph, userPrompt: "x" }).instrumental).toBe(false)
233
+ expect(
234
+ assembleSunoInput({ node: node({ instrumental: true }), graph: emptyGraph, userPrompt: "x" }).instrumental,
235
+ ).toBe(true)
236
+ })
237
+
238
+ it("forwards the three weights verbatim", () => {
239
+ const r = assembleSunoInput({
240
+ node: node({ styleWeight: 0.6, weirdnessConstraint: 0.3, audioWeight: 0.8 }),
241
+ graph: emptyGraph,
242
+ userPrompt: "x",
243
+ })
244
+ expect(r.styleWeight).toBe(0.6)
245
+ expect(r.weirdnessConstraint).toBe(0.3)
246
+ expect(r.audioWeight).toBe(0.8)
247
+ })
248
+
249
+ it("auto-detects custom mode from a typed title when customMode is unset", () => {
250
+ expect(
251
+ assembleSunoInput({ node: node({ title: "My Song" }), graph: emptyGraph, userPrompt: "x" }).customMode,
252
+ ).toBe(true)
253
+ })
254
+ })
255
+
256
+ describe("assembleSunoInput — throwOnEmpty (divergence A)", () => {
257
+ it("throws when throwOnEmpty AND no userPrompt AND no hint", () => {
258
+ expect(() =>
259
+ assembleSunoInput({ node: node({}), graph: emptyGraph, userPrompt: "", throwOnEmpty: true }),
260
+ ).toThrow()
261
+ })
262
+
263
+ it("does NOT throw (returns bare empty prompt) when throwOnEmpty is false", () => {
264
+ expect(
265
+ assembleSunoInput({ node: node({}), graph: emptyGraph, userPrompt: "", throwOnEmpty: false }).prompt,
266
+ ).toBe("")
267
+ })
268
+
269
+ it("does NOT throw when throwOnEmpty is omitted (defaults to false — BE/preview parity)", () => {
270
+ expect(assembleSunoInput({ node: node({}), graph: emptyGraph, userPrompt: "" }).prompt).toBe("")
271
+ })
272
+
273
+ it("does NOT throw when the prompt is empty but a connected hint fills it", () => {
274
+ const g = graphWithHint()
275
+ const consumer = node({})
276
+ const hint = composeSoundHintFromConnections(consumer, "suno-generate", g).text
277
+ expect(() =>
278
+ assembleSunoInput({ node: consumer, graph: g, userPrompt: "", throwOnEmpty: true }),
279
+ ).not.toThrow()
280
+ const r = assembleSunoInput({ node: consumer, graph: g, userPrompt: "", throwOnEmpty: true })
281
+ expect(r.prompt).toBe(hint)
282
+ })
283
+ })
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ BRAND_PRESET_IDS,
4
+ BRAND_PRESETS,
5
+ BRAND_PRESET_META,
6
+ resolveBrandInput,
7
+ type BrandTokens,
8
+ } from "../brand-tokens.js"
9
+ import { SUPPORTED_FONT_NAMES } from "@nodaro/shared"
10
+
11
+ const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
12
+
13
+ describe("brand presets", () => {
14
+ it("BRAND_PRESET_IDS exactly matches BRAND_PRESETS keys", () => {
15
+ expect([...BRAND_PRESET_IDS].sort()).toEqual(Object.keys(BRAND_PRESETS).sort())
16
+ })
17
+
18
+ it("BRAND_PRESET_META has an entry per id with matching id field", () => {
19
+ for (const id of BRAND_PRESET_IDS) {
20
+ expect(BRAND_PRESET_META[id]).toBeDefined()
21
+ expect(BRAND_PRESET_META[id].id).toBe(id)
22
+ expect(BRAND_PRESET_META[id].label.length).toBeGreaterThan(0)
23
+ }
24
+ })
25
+
26
+ it("ships exactly 8 presets", () => {
27
+ expect(BRAND_PRESET_IDS.length).toBe(8)
28
+ })
29
+
30
+ it("every preset uses supported fonts and valid hex palette colors", () => {
31
+ for (const id of BRAND_PRESET_IDS) {
32
+ const t = BRAND_PRESETS[id]
33
+ expect(SUPPORTED_FONT_NAMES).toContain(t.fonts.heading)
34
+ expect(SUPPORTED_FONT_NAMES).toContain(t.fonts.body)
35
+ for (const [k, v] of Object.entries(t.palette)) {
36
+ if (v === undefined) continue
37
+ expect(v, `${id}.palette.${k}`).toMatch(HEX)
38
+ }
39
+ }
40
+ })
41
+ })
42
+
43
+ describe("resolveBrandInput", () => {
44
+ it("resolves a preset name to its tokens", () => {
45
+ const first = BRAND_PRESET_IDS[0]
46
+ expect(resolveBrandInput(first)).toEqual(BRAND_PRESETS[first])
47
+ })
48
+
49
+ it("passes an inline BrandTokens object through unchanged", () => {
50
+ const inline: BrandTokens = {
51
+ palette: { bg: "#000000", text: "#ffffff", accent: "#ff0000" },
52
+ fonts: { heading: "Anton", body: "Inter" },
53
+ }
54
+ expect(resolveBrandInput(inline)).toBe(inline)
55
+ })
56
+
57
+ it("throws a clear error on an unknown preset name", () => {
58
+ expect(() => resolveBrandInput("does-not-exist")).toThrow(/unknown brand preset/i)
59
+ })
60
+
61
+ it("throws on prototype-pollution-shaped keys instead of returning a prototype member", () => {
62
+ expect(() => resolveBrandInput("__proto__")).toThrow(/unknown brand preset/i)
63
+ expect(() => resolveBrandInput("constructor")).toThrow(/unknown brand preset/i)
64
+ })
65
+ })
66
+
67
+ test("every preset declares heading+body typography weight", () => {
68
+ for (const id of BRAND_PRESET_IDS) {
69
+ const f = BRAND_PRESETS[id].fonts
70
+ expect(typeof f.headingType?.weight).toBe("number")
71
+ expect(typeof f.bodyType?.weight).toBe("number")
72
+ }
73
+ })
74
+ test("poster-contrast forces uppercase heading + body, mono-slate wide-tracked heading", () => {
75
+ expect(BRAND_PRESETS["poster-contrast"].fonts.headingType?.casing).toBe("uppercase")
76
+ expect(BRAND_PRESETS["poster-contrast"].fonts.bodyType?.tracking).toBe(0.06)
77
+ expect(BRAND_PRESETS["mono-slate"].fonts.headingType?.tracking).toBe(0.12)
78
+ })
79
+ test("resolveBrandInput passes typography through for a preset name", () => {
80
+ expect(resolveBrandInput("vibrant-pulse").fonts.headingType?.weight).toBe(900)
81
+ })
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildImagePrompt } from "../prompt-builder.js"
3
+ import type { ConnectedReference } from "@nodaro/shared"
4
+
5
+ // A held-prop / styling / text element wired into a Character node carries a
6
+ // resolved `elementInjection` fragment. It must be woven INTO that character's
7
+ // own identity bullet under "Use these characters:" — NOT appended at the global
8
+ // tail (which is what wiring the element directly to the consumer's `elements`
9
+ // handle does). This is the user-reported bug: the smartphone hint showed up at
10
+ // the end of the whole prompt instead of as part of Image 1 (Alice).
11
+ const PROP = "holding a modern smartphone in one hand, fingers wrapped naturally around the device"
12
+
13
+ const aliceWithProp: ConnectedReference = {
14
+ id: "ref-alice",
15
+ defaultName: "Alice",
16
+ source: "wired-character",
17
+ description: "young woman",
18
+ url: "https://r2/alice.png",
19
+ characterSlug: "alice",
20
+ variantSlug: undefined,
21
+ characterCanonicalDescription: "young woman, hazel eyes, long dark hair, golden tan",
22
+ elementInjection: PROP,
23
+ variantDescription: null,
24
+ variantDisplayName: "canonical",
25
+ }
26
+
27
+ const aliceNoProp: ConnectedReference = {
28
+ ...aliceWithProp,
29
+ elementInjection: undefined,
30
+ }
31
+
32
+ const victoria: ConnectedReference = {
33
+ id: "ref-victoria",
34
+ defaultName: "Victoria",
35
+ source: "wired-character",
36
+ description: "young woman",
37
+ url: "https://r2/victoria.png",
38
+ characterSlug: "victoria",
39
+ variantSlug: undefined,
40
+ characterCanonicalDescription: "young woman, grey-green eyes, medium brown hair",
41
+ variantDescription: null,
42
+ variantDisplayName: "canonical",
43
+ }
44
+
45
+ /** The directive block is everything before the first blank line. */
46
+ function directiveBlock(prompt: string): string {
47
+ const i = prompt.indexOf("\n\n")
48
+ return i === -1 ? prompt : prompt.slice(0, i)
49
+ }
50
+
51
+ describe("buildImagePrompt — ConnectedReference.elementInjection", () => {
52
+ it("canonical fallback: weaves the injection INTO the character's bullet, not the prompt tail", () => {
53
+ const result = buildImagePrompt({
54
+ prompt: "a woman in a cafe",
55
+ provider: "nano-banana-pro",
56
+ connectedReferences: [aliceWithProp],
57
+ })
58
+ const block = directiveBlock(result.prompt)
59
+ // The prop is inside the "Use these characters:" block...
60
+ expect(block).toContain(PROP)
61
+ // ...specifically inside Alice's own bullet, after her canonical description
62
+ // and before the identity-lock directive.
63
+ expect(result.prompt).toMatch(
64
+ /- Image 1 \(Alice\) — young woman, hazel eyes, long dark hair, golden tan\. holding a modern smartphone[^\n]*\. The subject must remain/,
65
+ )
66
+ // ...and NOT dangling at the very end like a direct `elements` connection.
67
+ expect(result.prompt.trimEnd().endsWith(PROP)).toBe(false)
68
+ })
69
+
70
+ it("scopes the injection to the wired character only (multi-character)", () => {
71
+ const result = buildImagePrompt({
72
+ prompt: "two women talking",
73
+ provider: "nano-banana-pro",
74
+ connectedReferences: [aliceWithProp, victoria],
75
+ })
76
+ // Alice's bullet carries the prop; Victoria's does not.
77
+ expect(result.prompt).toMatch(/- Image 1 \(Alice\)[^\n]*holding a modern smartphone/)
78
+ expect(result.prompt).toMatch(/- Image 2 \(Victoria\)(?:(?!holding a modern smartphone)[^\n])*\n/)
79
+ // The prop appears exactly once.
80
+ expect((result.prompt.match(/holding a modern smartphone/g) || []).length).toBe(1)
81
+ })
82
+
83
+ it("@-mention: injection rides the mentioned character's bullet", () => {
84
+ const result = buildImagePrompt({
85
+ prompt: "feature @alice:1 prominently",
86
+ provider: "nano-banana-pro",
87
+ connectedReferences: [aliceWithProp],
88
+ })
89
+ expect(result.prompt).toMatch(/- Image 1 \(Alice\)[^\n]*holding a modern smartphone/)
90
+ })
91
+
92
+ it("rides the bullet even with NO canonical description (mode-independent)", () => {
93
+ const result = buildImagePrompt({
94
+ prompt: "a woman in a cafe",
95
+ provider: "nano-banana-pro",
96
+ connectedReferences: [{ ...aliceWithProp, characterCanonicalDescription: null }],
97
+ })
98
+ // No canonical desc, but the prop still attaches to Alice's subject.
99
+ expect(result.prompt).toMatch(/- Image 1 \(Alice\) — holding a modern smartphone/)
100
+ })
101
+
102
+ it("byte-identical to today when elementInjection is absent", () => {
103
+ const withUndef = buildImagePrompt({
104
+ prompt: "a woman in a cafe",
105
+ provider: "nano-banana-pro",
106
+ connectedReferences: [aliceNoProp],
107
+ })
108
+ const withEmpty = buildImagePrompt({
109
+ prompt: "a woman in a cafe",
110
+ provider: "nano-banana-pro",
111
+ connectedReferences: [{ ...aliceNoProp, elementInjection: " " }],
112
+ })
113
+ // Whitespace-only injection collapses to the same output as no injection.
114
+ expect(withEmpty.prompt).toBe(withUndef.prompt)
115
+ // And the prop text never appears.
116
+ expect(withUndef.prompt).not.toContain("smartphone")
117
+ })
118
+ })
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Hybrid reference format (images-only, flag-gated via
3
+ * `BuildImagePromptConfig.referenceFormat === "hybrid"`).
4
+ *
5
+ * TOKEN EXPANSION ONLY: every `{image:N:label}` token becomes
6
+ * "the <label> from reference image <LETTER>" (1=A, 2=B, …). NO reference-lock
7
+ * snippet is auto-injected — authors prepend their own lock in the prompt text
8
+ * (it passes through untouched). A caller may optionally prepend one via
9
+ * `referenceLockSnippet`.
10
+ *
11
+ * Legacy (default, no `referenceFormat`) stays the "Use these references:/
12
+ * Compose:" wrap with numeric `Image N` directives.
13
+ */
14
+
15
+ import { describe, it, expect } from "vitest"
16
+ import { buildImagePrompt } from "../prompt-builder.js"
17
+ import type { ConnectedReference } from "@nodaro/shared"
18
+
19
+ const PROVIDER = "nano-banana-pro" // in MODELS_WITH_REFERENCE_IMAGE_SUPPORT
20
+
21
+ const urlA = "https://r2/person-1.png" // image 1 → A
22
+ const urlB = "https://r2/clothes.png" // image 2 → B
23
+ const urlC = "https://r2/location.png" // image 3 → C
24
+ const urlD = "https://r2/person-2.png" // image 4 → D
25
+
26
+ const refs: ConnectedReference[] = [
27
+ { id: "n1", defaultName: "Person 1", source: "wired-image", url: urlA },
28
+ { id: "n2", defaultName: "Outfit", source: "wired-image", url: urlB },
29
+ { id: "n3", defaultName: "Suite", source: "wired-image", url: urlC },
30
+ { id: "n4", defaultName: "Person 2", source: "wired-image", url: urlD },
31
+ ]
32
+
33
+ const TEMPLATE = [
34
+ "{image:1:subject} is wearing {image:2:clothing}.",
35
+ "{image:1:subject} is sitting, smiling and holding {image:3:glass}",
36
+ "{image:4:subject} is wearing {image:1:clothing}.",
37
+ "{image:4:subject} is standing and holding {image:3:smartphone}",
38
+ "{image:3:location}",
39
+ ].join("\n")
40
+
41
+ describe("hybrid reference format (images-only, flag-gated)", () => {
42
+ it("expands tokens uniformly and does NOT auto-inject a lock", () => {
43
+ const { prompt } = buildImagePrompt({
44
+ provider: PROVIDER,
45
+ prompt: TEMPLATE,
46
+ connectedReferences: refs,
47
+ referenceFormat: "hybrid",
48
+ })
49
+
50
+ // no auto-lock: output starts straight with the expanded scene
51
+ expect(
52
+ prompt.startsWith(
53
+ "The subject from reference image A is wearing the clothing from reference image B.",
54
+ ),
55
+ ).toBe(true)
56
+ expect(prompt).not.toContain("Do not take anything from reference images")
57
+ expect(prompt).not.toContain("Do not use anything from reference images")
58
+ expect(prompt).not.toContain("must preserve likeness")
59
+ expect(prompt).not.toContain("Compose them naturally")
60
+
61
+ // uniform "the <label> from reference image <LETTER>"
62
+ expect(prompt).toContain(
63
+ "The subject from reference image A is sitting, smiling and holding the glass from reference image C",
64
+ )
65
+ // cross-route: subject D wears subject A's clothing
66
+ expect(prompt).toContain(
67
+ "The subject from reference image D is wearing the clothing from reference image A",
68
+ )
69
+ expect(prompt).toContain("The location from reference image C")
70
+
71
+ // not legacy
72
+ expect(prompt).not.toContain("Use these references")
73
+ expect(prompt).not.toContain("Image 1 (")
74
+ })
75
+
76
+ it("passes an author-typed lock snippet through unchanged while expanding tokens", () => {
77
+ const lockLine = "Do not use anything from reference images unless specified explicitly."
78
+ const { prompt } = buildImagePrompt({
79
+ provider: PROVIDER,
80
+ prompt: `${lockLine}\n{image:1:subject} smiling.`,
81
+ connectedReferences: [refs[0]],
82
+ referenceFormat: "hybrid",
83
+ })
84
+ expect(prompt).toBe(`${lockLine}\nThe subject from reference image A smiling.`)
85
+ })
86
+
87
+ it("supports multi-word labels (widened token regex)", () => {
88
+ const { prompt } = buildImagePrompt({
89
+ provider: PROVIDER,
90
+ prompt: "{image:1:person} is wearing {image:2:clothes and shoes}.",
91
+ connectedReferences: [refs[0], refs[1]],
92
+ referenceFormat: "hybrid",
93
+ })
94
+ expect(prompt).toContain(
95
+ "The person from reference image A is wearing the clothes and shoes from reference image B.",
96
+ )
97
+ })
98
+
99
+ it("optionally prepends a caller-supplied referenceLockSnippet", () => {
100
+ const lock = "Take only what is specified. Do not take anything else."
101
+ const { prompt } = buildImagePrompt({
102
+ provider: PROVIDER,
103
+ prompt: "{image:1:clothing}.",
104
+ connectedReferences: [refs[0]],
105
+ referenceFormat: "hybrid",
106
+ referenceLockSnippet: lock,
107
+ })
108
+ expect(prompt.startsWith(`${lock}\n`)).toBe(true)
109
+ expect(prompt).toContain("The clothing from reference image A.")
110
+ })
111
+
112
+ it("returns the 4 reference URLs in slot order (A,B,C,D)", () => {
113
+ const { referenceImageUrls } = buildImagePrompt({
114
+ provider: PROVIDER,
115
+ prompt: TEMPLATE,
116
+ connectedReferences: refs,
117
+ referenceFormat: "hybrid",
118
+ })
119
+ expect(referenceImageUrls).toEqual([urlA, urlB, urlC, urlD])
120
+ })
121
+
122
+ it("legacy (default, no referenceFormat) is unchanged", () => {
123
+ const { prompt } = buildImagePrompt({
124
+ provider: PROVIDER,
125
+ prompt: TEMPLATE,
126
+ connectedReferences: refs,
127
+ })
128
+ expect(prompt).toContain("Use these references for the output image:")
129
+ expect(prompt).not.toContain("the clothing from reference image")
130
+ })
131
+ })