@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,57 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildImagePrompt } from "../prompt-builder.js"
3
+ import type { ConnectedReference } from "@nodaro/shared"
4
+
5
+ /**
6
+ * Per-provider reference cap in `buildImagePrompt`. The canvas enforces each
7
+ * model's image-reference limit via handle-limits, but the direct API / MCP /
8
+ * SDK path only Zod-caps `connectedReferences` at a flat 14. Without a cap in
9
+ * the shared assembler, an over-cap caller would get `Image N` directives the
10
+ * provider silently drops. The cap is applied UP FRONT (before numbering), so
11
+ * directives and URLs stay in lock-step — never a binding past the limit.
12
+ */
13
+
14
+ function manualRefs(n: number): ConnectedReference[] {
15
+ return Array.from({ length: n }, (_, i) => ({
16
+ id: `m${i}`,
17
+ defaultName: `ref${i}`,
18
+ source: "manual" as const,
19
+ url: `https://r2/ref-${i}.png`,
20
+ description: `reference ${i}`,
21
+ }))
22
+ }
23
+
24
+ describe("buildImagePrompt — per-provider reference cap (API/MCP over-cap guard)", () => {
25
+ it("caps structured references to the provider's image-ref limit; no Image N past it (flux-i2i = 4)", () => {
26
+ const result = buildImagePrompt({
27
+ prompt: "a composite scene",
28
+ provider: "flux-i2i", // REF_IMAGE_MAX_LIMITS = 4
29
+ connectedReferences: manualRefs(6),
30
+ })
31
+ expect(result.referenceImageUrls).toHaveLength(4)
32
+ expect(result.prompt).not.toContain("Image 5")
33
+ expect(result.prompt).not.toContain("Image 6")
34
+ })
35
+
36
+ it("flat referenceImageUrls consume the budget before structured refs (nano-banana-pro = 8)", () => {
37
+ const flat = Array.from({ length: 6 }, (_, i) => `https://r2/flat-${i}.png`)
38
+ const result = buildImagePrompt({
39
+ prompt: "scene",
40
+ provider: "nano-banana-pro", // cap 8
41
+ referenceImageUrls: flat,
42
+ connectedReferences: manualRefs(6),
43
+ })
44
+ // 6 flat (numbered first) + 2 structured = 8; the other 4 structured drop.
45
+ expect(result.referenceImageUrls).toHaveLength(8)
46
+ expect(result.prompt).not.toContain("Image 9")
47
+ })
48
+
49
+ it("does not cap when the ref count is within the provider's limit (nano-banana-pro = 8)", () => {
50
+ const result = buildImagePrompt({
51
+ prompt: "scene",
52
+ provider: "nano-banana-pro",
53
+ connectedReferences: manualRefs(3),
54
+ })
55
+ expect(result.referenceImageUrls).toHaveLength(3)
56
+ })
57
+ })
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Reference-image numbering + non-character directive coverage.
3
+ *
4
+ * Regression suite for the `buildImagePrompt` defect where a no-`@`-mention
5
+ * client (chips → connectedReferences + free-text) produced:
6
+ * - Bug A: character `Image N (Name)` directives pointed at the WRONG image
7
+ * because the final URL order prepended non-character refs while the
8
+ * directive numbers were computed against a different (non-prepended) order.
9
+ * - Bug B: unmentioned `wired-location` / `wired-object` refs were attached
10
+ * as images but received NO directive (model never told "this is the
11
+ * setting / this object").
12
+ *
13
+ * The load-bearing invariant these tests lock:
14
+ * For the returned `referenceImageUrls`, the URL at index N-1 is the subject
15
+ * of every `Image N (…)` directive in the returned prompt.
16
+ */
17
+
18
+ import { describe, it, expect } from "vitest"
19
+ import { buildImagePrompt } from "../prompt-builder.js"
20
+ import type { ConnectedReference } from "@nodaro/shared"
21
+
22
+ const PROVIDER = "nano-banana-pro" // in MODELS_WITH_REFERENCE_IMAGE_SUPPORT
23
+
24
+ // Identifiable URLs so an off-by-one is obvious in failure output.
25
+ const adamUrl = "https://r2/adam.png"
26
+ const ema2Url = "https://r2/ema2.png"
27
+ const cozyNestUrl = "https://r2/cozy-nest.png"
28
+ const swordUrl = "https://r2/sword.png"
29
+ const womanUrl1 = "https://r2/woman-1.png"
30
+ const womanUrl2 = "https://r2/woman-2.png"
31
+ const womanUrl3 = "https://r2/woman-3.png"
32
+
33
+ const adam: ConnectedReference = {
34
+ id: "node-adam",
35
+ defaultName: "Adam",
36
+ source: "wired-character",
37
+ url: adamUrl,
38
+ characterSlug: "adam",
39
+ variantSlug: undefined,
40
+ characterCanonicalDescription: "older man, grey hair, broad shoulders",
41
+ }
42
+
43
+ const ema2: ConnectedReference = {
44
+ id: "node-ema2",
45
+ defaultName: "EMA2",
46
+ source: "wired-character",
47
+ url: ema2Url,
48
+ characterSlug: "ema2",
49
+ variantSlug: undefined,
50
+ characterCanonicalDescription: "young woman, dark hair",
51
+ }
52
+
53
+ const cozyNest: ConnectedReference = {
54
+ id: "node-cozy-nest",
55
+ defaultName: "Cozy Nest",
56
+ source: "wired-location",
57
+ url: cozyNestUrl,
58
+ locationSlug: "cozy-nest",
59
+ locationCanonicalDescription: "A glowing forest nest, warm bioluminescence, soft moss",
60
+ }
61
+
62
+ const swordObject: ConnectedReference = {
63
+ id: "node-sword",
64
+ defaultName: "Hero Sword",
65
+ source: "wired-object",
66
+ url: swordUrl,
67
+ description: "an ornate golden sword with a ruby pommel",
68
+ }
69
+
70
+ /**
71
+ * Generic Bug-A invariant: every `Image N (Name…)` directive's index must
72
+ * resolve, in the returned `referenceImageUrls`, to the URL we expect for that
73
+ * name. `nameToUrl` maps the leading parenthetical token (before any " — desc")
74
+ * to its expected URL. Names not in the map are ignored (e.g. opaque manual
75
+ * uploads that carry no directive).
76
+ */
77
+ function assertDirectiveIndicesMatchSlots(
78
+ prompt: string,
79
+ refs: readonly string[] | undefined,
80
+ nameToUrl: Record<string, string>,
81
+ ): void {
82
+ expect(refs).toBeDefined()
83
+ const re = /Image (\d+) \(([^)]+?)(?: —|\))/g
84
+ let sawOne = false
85
+ for (const m of prompt.matchAll(re)) {
86
+ const n = parseInt(m[1], 10)
87
+ const name = m[2].trim()
88
+ const expectedUrl = nameToUrl[name]
89
+ if (!expectedUrl) continue
90
+ sawOne = true
91
+ expect(refs![n - 1]).toBe(expectedUrl)
92
+ }
93
+ expect(sawOne).toBe(true) // guard against a regex that matched nothing
94
+ }
95
+
96
+ describe("buildImagePrompt — reference numbering (Bug A)", () => {
97
+ it("character directives point at the URL actually in their slot (the repro)", () => {
98
+ const result = buildImagePrompt({
99
+ // NOTE: bare names only — NO @adam:1 / @cozy-nest:1 tokens.
100
+ prompt: "Adam and EMA2 stand with the women at Cozy Nest, centered",
101
+ provider: PROVIDER,
102
+ referenceImageUrls: [womanUrl1, womanUrl2, womanUrl3],
103
+ connectedReferences: [adam, ema2, cozyNest],
104
+ })
105
+
106
+ // The core invariant: each Image N directive resolves to the right URL.
107
+ assertDirectiveIndicesMatchSlots(result.prompt, result.referenceImageUrls, {
108
+ Adam: adamUrl,
109
+ EMA2: ema2Url,
110
+ location: cozyNestUrl,
111
+ })
112
+
113
+ // Concrete order (documents the fix: manual refs keep their slots, entities
114
+ // follow, location appended last — never prepended).
115
+ expect(result.referenceImageUrls).toEqual([
116
+ womanUrl1,
117
+ womanUrl2,
118
+ womanUrl3,
119
+ adamUrl,
120
+ ema2Url,
121
+ cozyNestUrl,
122
+ ])
123
+ })
124
+ })
125
+
126
+ describe("buildImagePrompt — non-character canonical fallback (Bug B)", () => {
127
+ it("unmentioned wired-location gets a 'setting' directive carrying its canonical description", () => {
128
+ const result = buildImagePrompt({
129
+ prompt: "a wide establishing shot",
130
+ provider: PROVIDER,
131
+ connectedReferences: [cozyNest],
132
+ })
133
+
134
+ expect(result.referenceImageUrls).toEqual([cozyNestUrl])
135
+ expect(result.prompt).toContain("use as the background/setting")
136
+ expect(result.prompt).toContain("A glowing forest nest")
137
+ // Directive index lines up with the URL slot.
138
+ assertDirectiveIndicesMatchSlots(result.prompt, result.referenceImageUrls, {
139
+ location: cozyNestUrl,
140
+ })
141
+ })
142
+
143
+ it("unmentioned wired-object gets a directive describing the object", () => {
144
+ const result = buildImagePrompt({
145
+ prompt: "a knight holds it aloft",
146
+ provider: PROVIDER,
147
+ connectedReferences: [swordObject],
148
+ })
149
+
150
+ expect(result.referenceImageUrls).toEqual([swordUrl])
151
+ expect(result.prompt).toContain("Image 1 (object — an ornate golden sword with a ruby pommel)")
152
+ expect(result.prompt).toContain("match exactly")
153
+ assertDirectiveIndicesMatchSlots(result.prompt, result.referenceImageUrls, {
154
+ object: swordUrl,
155
+ })
156
+ })
157
+
158
+ it("emits ONE directive (and one URL slot) when the same location is wired twice", () => {
159
+ const dupe: ConnectedReference = { ...cozyNest, id: "node-cozy-nest-dupe" }
160
+ const result = buildImagePrompt({
161
+ prompt: "a scene",
162
+ provider: PROVIDER,
163
+ connectedReferences: [cozyNest, dupe],
164
+ })
165
+ // Same URL → one slot, one directive (no double-emit at the same index).
166
+ expect(result.referenceImageUrls).toEqual([cozyNestUrl])
167
+ const locationDirectives = (result.prompt.match(/Image \d+ \(location/g) ?? []).length
168
+ expect(locationDirectives).toBe(1)
169
+ })
170
+
171
+ it("respects suppressedCanonicalLocationIds — directive emitted but canonical text dropped", () => {
172
+ const result = buildImagePrompt({
173
+ prompt: "a scene",
174
+ provider: PROVIDER,
175
+ connectedReferences: [cozyNest],
176
+ suppressedCanonicalLocationIds: ["cozy-nest"],
177
+ })
178
+ // URL still attaches and the positional directive still fires…
179
+ expect(result.referenceImageUrls).toEqual([cozyNestUrl])
180
+ expect(result.prompt).toContain("Image 1 (location)")
181
+ // …but the canonical description is suppressed.
182
+ expect(result.prompt).not.toContain("A glowing forest nest")
183
+ })
184
+ })
185
+
186
+ describe("buildImagePrompt — mixed fallback (characters + location + manual)", () => {
187
+ it("numbers character fallback, location fallback, and manual refs consistently", () => {
188
+ const result = buildImagePrompt({
189
+ prompt: "a scene",
190
+ provider: PROVIDER,
191
+ referenceImageUrls: [womanUrl1, womanUrl2], // manual uploads, opaque
192
+ connectedReferences: [adam, cozyNest],
193
+ })
194
+
195
+ // Final order: manual (1,2) → character fallback (3) → location fallback (4).
196
+ expect(result.referenceImageUrls).toEqual([
197
+ womanUrl1,
198
+ womanUrl2,
199
+ adamUrl,
200
+ cozyNestUrl,
201
+ ])
202
+ assertDirectiveIndicesMatchSlots(result.prompt, result.referenceImageUrls, {
203
+ Adam: adamUrl,
204
+ location: cozyNestUrl,
205
+ })
206
+ // Manual uploads occupy slots 1-2 but carry NO directive (opaque URLs).
207
+ expect(result.prompt).not.toMatch(/Image 1 \(/)
208
+ expect(result.prompt).not.toMatch(/Image 2 \(/)
209
+ // The labeled directives land at 3 and 4.
210
+ expect(result.prompt).toContain("Image 3 (Adam)")
211
+ expect(result.prompt).toMatch(/Image 4 \(location/)
212
+ })
213
+ })
214
+
215
+ describe("buildImagePrompt — @-mention path is unaffected (no regression)", () => {
216
+ it("fully @-mentioned character + location resolves identically (byte-for-byte stable)", () => {
217
+ const result = buildImagePrompt({
218
+ prompt: "@adam:1 stands at @cozy-nest:1",
219
+ provider: PROVIDER,
220
+ connectedReferences: [adam, cozyNest],
221
+ })
222
+
223
+ // Mention URLs attach in mention order; no prepend/append shuffle.
224
+ expect(result.referenceImageUrls).toEqual([adamUrl, cozyNestUrl])
225
+ // Character + location mention blocks both present, with their typed index.
226
+ expect(result.prompt).toContain("Use these characters:")
227
+ expect(result.prompt).toContain("Image 1 (Adam)")
228
+ expect(result.prompt).toContain("Use these locations:")
229
+ expect(result.prompt).toContain("Image 1 (Cozy Nest)")
230
+ // Inline tokens replaced by display names.
231
+ expect(result.prompt).toContain("Adam stands at Cozy Nest")
232
+ expect(result.prompt).not.toMatch(/@adam:1\b/)
233
+ expect(result.prompt).not.toMatch(/@cozy-nest:1\b/)
234
+ // The location is NOT double-emitted via a fallback directive.
235
+ expect(result.prompt).not.toMatch(/Image \d+ \(location/)
236
+ })
237
+ })
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Reference-order parity tests.
3
+ *
4
+ * `buildImagePrompt` now accepts an optional `referenceOrder` parameter
5
+ * (stable tile-IDs from `compute-injected-refs.ts`) AND
6
+ * `suppressedCanonicalCharacterIds` (slugs whose canonical-fallback the user
7
+ * has hidden). These tests pin:
8
+ * - reorder remaps both URLs AND `Image N` directives consistently
9
+ * - empty / stale referenceOrder is a no-op
10
+ * - suppression drops the URL + the directive line
11
+ * - identical fixtures produce identical URL lists on frontend (via
12
+ * `computeInjectedRefs`) and backend (via `buildImagePrompt` with
13
+ * `referenceOrder`) — load-bearing for cross-process parity.
14
+ */
15
+
16
+ import { describe, it, expect } from "vitest"
17
+ import { buildImagePrompt } from "../prompt-builder.js"
18
+ import type { ConnectedReference } from "@nodaro/shared"
19
+
20
+ const wiredUpload: ConnectedReference = {
21
+ id: "node-upload-1",
22
+ defaultName: "Upload 1",
23
+ source: "wired-image",
24
+ url: "https://r2/upload-1.png",
25
+ }
26
+
27
+ const kiraCanonical: ConnectedReference = {
28
+ id: "node-kira",
29
+ defaultName: "Kira",
30
+ source: "wired-character",
31
+ description: "young woman with warm smile",
32
+ url: "https://r2/kira-portrait.png",
33
+ characterSlug: "kira",
34
+ variantSlug: undefined,
35
+ characterCanonicalDescription: "young woman, brown eyes, auburn hair",
36
+ variantDescription: null,
37
+ variantDisplayName: "canonical",
38
+ }
39
+
40
+ const kiraSmile: ConnectedReference = {
41
+ id: "node-kira_expr_smile",
42
+ defaultName: "Kira / smile",
43
+ source: "wired-character",
44
+ description: "warm closed-mouth smile",
45
+ url: "https://r2/kira-smile.png",
46
+ characterSlug: "kira",
47
+ variantSlug: "smile",
48
+ characterCanonicalDescription: "young woman, brown eyes, auburn hair",
49
+ variantDescription: "warm closed-mouth smile",
50
+ variantDisplayName: "smile",
51
+ }
52
+
53
+ const adamCanonical: ConnectedReference = {
54
+ id: "node-adam",
55
+ defaultName: "Adam",
56
+ source: "wired-character",
57
+ description: "older man",
58
+ url: "https://r2/adam-portrait.png",
59
+ characterSlug: "adam",
60
+ variantSlug: undefined,
61
+ characterCanonicalDescription: "older man, grey hair",
62
+ variantDescription: null,
63
+ variantDisplayName: "canonical",
64
+ }
65
+
66
+ describe("buildImagePrompt — referenceOrder", () => {
67
+ it("returns natural URL order when no referenceOrder is provided", () => {
68
+ const result = buildImagePrompt({
69
+ prompt: "scene of @kira:1:smile and Adam",
70
+ provider: "nano-banana-pro",
71
+ connectedReferences: [kiraCanonical, kiraSmile, adamCanonical],
72
+ })
73
+ // Natural order from buildImagePrompt:
74
+ // referenceImageUrls + mentions + canonical-fallback
75
+ // = [] + [kiraSmile] + [adamCanonical]
76
+ expect(result.referenceImageUrls).toEqual([
77
+ kiraSmile.url,
78
+ adamCanonical.url,
79
+ ])
80
+ })
81
+
82
+ it("reorders URLs to match referenceOrder", () => {
83
+ const result = buildImagePrompt({
84
+ prompt: "scene of @kira:1:smile and Adam",
85
+ provider: "nano-banana-pro",
86
+ connectedReferences: [kiraCanonical, kiraSmile, adamCanonical],
87
+ referenceOrder: [
88
+ "char-canonical:adam",
89
+ "mention:kira:smile",
90
+ ],
91
+ })
92
+ expect(result.referenceImageUrls).toEqual([
93
+ adamCanonical.url,
94
+ kiraSmile.url,
95
+ ])
96
+ })
97
+
98
+ it("renumbers Image N directives consistently after reorder", () => {
99
+ const result = buildImagePrompt({
100
+ prompt: "scene of @kira:1:smile and Adam",
101
+ provider: "nano-banana-pro",
102
+ connectedReferences: [kiraCanonical, kiraSmile, adamCanonical],
103
+ referenceOrder: [
104
+ "char-canonical:adam",
105
+ "mention:kira:smile",
106
+ ],
107
+ })
108
+ // After reorder Adam is Image 1, Kira is Image 2 — directives must follow.
109
+ // Kira's directive used to say "Image 1 (Kira)" since the mention was
110
+ // `@kira:1:smile`; the renumber rewrites it to "Image 2 (Kira)".
111
+ expect(result.prompt).toContain("Image 1 (Adam)")
112
+ expect(result.prompt).toContain("Image 2 (Kira)")
113
+ // And does NOT contain the old positions.
114
+ expect(result.prompt).not.toMatch(/Image 1 \(Kira\)/)
115
+ expect(result.prompt).not.toMatch(/Image 2 \(Adam\)/)
116
+ })
117
+
118
+ it("ignores stale IDs in referenceOrder without breaking the layout", () => {
119
+ const result = buildImagePrompt({
120
+ prompt: "scene of @kira:1:smile and Adam",
121
+ provider: "nano-banana-pro",
122
+ connectedReferences: [kiraCanonical, kiraSmile, adamCanonical],
123
+ referenceOrder: [
124
+ "stale:nonexistent",
125
+ "char-canonical:adam",
126
+ "wired:also-deleted",
127
+ ],
128
+ })
129
+ // adam goes first (matched), then natural-order tail (kira smile).
130
+ expect(result.referenceImageUrls).toEqual([
131
+ adamCanonical.url,
132
+ kiraSmile.url,
133
+ ])
134
+ })
135
+
136
+ it("empty referenceOrder is a no-op (identical output to no order)", () => {
137
+ const a = buildImagePrompt({
138
+ prompt: "scene of @kira:1:smile",
139
+ provider: "nano-banana-pro",
140
+ connectedReferences: [kiraCanonical, kiraSmile, adamCanonical],
141
+ })
142
+ const b = buildImagePrompt({
143
+ prompt: "scene of @kira:1:smile",
144
+ provider: "nano-banana-pro",
145
+ connectedReferences: [kiraCanonical, kiraSmile, adamCanonical],
146
+ referenceOrder: [],
147
+ })
148
+ expect(a.prompt).toEqual(b.prompt)
149
+ expect(a.referenceImageUrls).toEqual(b.referenceImageUrls)
150
+ })
151
+
152
+ it("identity reorder (same as natural order) is a no-op", () => {
153
+ const natural = buildImagePrompt({
154
+ prompt: "scene of @kira:1:smile and Adam",
155
+ provider: "nano-banana-pro",
156
+ connectedReferences: [kiraCanonical, kiraSmile, adamCanonical],
157
+ })
158
+ const identity = buildImagePrompt({
159
+ prompt: "scene of @kira:1:smile and Adam",
160
+ provider: "nano-banana-pro",
161
+ connectedReferences: [kiraCanonical, kiraSmile, adamCanonical],
162
+ referenceOrder: [
163
+ "mention:kira:smile",
164
+ "char-canonical:adam",
165
+ ],
166
+ })
167
+ expect(natural.prompt).toEqual(identity.prompt)
168
+ expect(natural.referenceImageUrls).toEqual(identity.referenceImageUrls)
169
+ })
170
+
171
+ it("handles wired raw + mention + canonical reorder together", () => {
172
+ const result = buildImagePrompt({
173
+ prompt: "scene of @kira:1:smile and Adam",
174
+ provider: "nano-banana-pro",
175
+ referenceImageUrls: [wiredUpload.url],
176
+ connectedReferences: [wiredUpload, kiraCanonical, kiraSmile, adamCanonical],
177
+ referenceOrder: [
178
+ "char-canonical:adam",
179
+ "wired:node-upload-1",
180
+ "mention:kira:smile",
181
+ ],
182
+ })
183
+ expect(result.referenceImageUrls).toEqual([
184
+ adamCanonical.url,
185
+ wiredUpload.url,
186
+ kiraSmile.url,
187
+ ])
188
+ })
189
+
190
+ it("preserves natural order for tiles missing from referenceOrder", () => {
191
+ const result = buildImagePrompt({
192
+ prompt: "@kira:1:smile and Adam",
193
+ provider: "nano-banana-pro",
194
+ connectedReferences: [kiraCanonical, kiraSmile, adamCanonical],
195
+ // Only one ID provided — the other tile falls through in natural order.
196
+ referenceOrder: ["char-canonical:adam"],
197
+ })
198
+ // adam first, then kira smile (natural tail).
199
+ expect(result.referenceImageUrls).toEqual([
200
+ adamCanonical.url,
201
+ kiraSmile.url,
202
+ ])
203
+ })
204
+
205
+ it("no-op when reference order moves a single-URL list", () => {
206
+ const result = buildImagePrompt({
207
+ prompt: "feature @kira:1",
208
+ provider: "nano-banana-pro",
209
+ connectedReferences: [kiraCanonical],
210
+ referenceOrder: ["mention:kira:canonical"],
211
+ })
212
+ // Only one URL to reorder — fast-path skips the regex.
213
+ expect(result.referenceImageUrls).toEqual([kiraCanonical.url])
214
+ expect(result.prompt).toContain("Image 1")
215
+ })
216
+ })
217
+
218
+ describe("buildImagePrompt — suppressedCanonicalCharacterIds", () => {
219
+ it("drops canonical URL + directive when slug is suppressed", () => {
220
+ const result = buildImagePrompt({
221
+ prompt: "",
222
+ provider: "nano-banana-pro",
223
+ connectedReferences: [kiraCanonical, adamCanonical],
224
+ suppressedCanonicalCharacterIds: ["kira"],
225
+ })
226
+ expect(result.referenceImageUrls).toEqual([adamCanonical.url])
227
+ // Kira directive should not appear.
228
+ expect(result.prompt).toContain("(Adam)")
229
+ expect(result.prompt).not.toContain("(Kira)")
230
+ })
231
+
232
+ it("does NOT drop @-mention URLs for a suppressed slug", () => {
233
+ const result = buildImagePrompt({
234
+ prompt: "@kira:1:smile",
235
+ provider: "nano-banana-pro",
236
+ connectedReferences: [kiraCanonical, kiraSmile],
237
+ suppressedCanonicalCharacterIds: ["kira"],
238
+ })
239
+ expect(result.referenceImageUrls).toEqual([kiraSmile.url])
240
+ })
241
+
242
+ it("does not affect non-character refs", () => {
243
+ const result = buildImagePrompt({
244
+ prompt: "",
245
+ provider: "nano-banana-pro",
246
+ connectedReferences: [wiredUpload, kiraCanonical, adamCanonical],
247
+ referenceImageUrls: [wiredUpload.url],
248
+ suppressedCanonicalCharacterIds: ["kira"],
249
+ })
250
+ expect(result.referenceImageUrls).toEqual([
251
+ wiredUpload.url,
252
+ adamCanonical.url,
253
+ ])
254
+ })
255
+ })
256
+
257
+ describe("buildImagePrompt — suppressedCanonicalLocationIds", () => {
258
+ // The location canonical-fallback path will be added in a follow-up PR (the
259
+ // Location Studio Phase 1 spec is what adds the actual `wired-location`
260
+ // canonical injection via `injected-reference-helpers.ts`). For now, this
261
+ // test pins the cross-cutting contract: the parameter is accepted, the
262
+ // signature matches `suppressedCanonicalCharacterIds`, and the helper never
263
+ // crashes when location ids are passed.
264
+
265
+ it("accepts suppressedCanonicalLocationIds as an empty array", () => {
266
+ const result = buildImagePrompt({
267
+ prompt: "scenic shot",
268
+ provider: "nano-banana-pro",
269
+ suppressedCanonicalLocationIds: [],
270
+ })
271
+ expect(result.prompt).toContain("scenic shot")
272
+ })
273
+
274
+ it("accepts a non-empty suppressedCanonicalLocationIds without crashing", () => {
275
+ const result = buildImagePrompt({
276
+ prompt: "scenic shot",
277
+ provider: "nano-banana-pro",
278
+ connectedReferences: [wiredUpload],
279
+ referenceImageUrls: [wiredUpload.url],
280
+ suppressedCanonicalLocationIds: ["beach", "rooftop"],
281
+ })
282
+ // Pre-existing refs flow through untouched; the parameter is a no-op
283
+ // until the location canonical-fallback path lands in a follow-up.
284
+ expect(result.referenceImageUrls).toEqual([wiredUpload.url])
285
+ })
286
+
287
+ it("does not affect character canonical refs", () => {
288
+ const result = buildImagePrompt({
289
+ prompt: "",
290
+ provider: "nano-banana-pro",
291
+ connectedReferences: [kiraCanonical, adamCanonical],
292
+ suppressedCanonicalLocationIds: ["kira", "adam"],
293
+ })
294
+ expect(result.referenceImageUrls).toEqual([
295
+ kiraCanonical.url,
296
+ adamCanonical.url,
297
+ ])
298
+ })
299
+
300
+ it("composes cleanly with suppressedCanonicalCharacterIds", () => {
301
+ const result = buildImagePrompt({
302
+ prompt: "",
303
+ provider: "nano-banana-pro",
304
+ connectedReferences: [kiraCanonical, adamCanonical],
305
+ suppressedCanonicalCharacterIds: ["kira"],
306
+ suppressedCanonicalLocationIds: ["beach"],
307
+ })
308
+ // Character suppression still drops Kira; location ids are inert today.
309
+ expect(result.referenceImageUrls).toEqual([adamCanonical.url])
310
+ expect(result.prompt).toContain("(Adam)")
311
+ expect(result.prompt).not.toContain("(Kira)")
312
+ })
313
+ })
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { composeCameraMotionHintFromConnections } from "../camera-motions.js"
3
+
4
+ describe("composeCameraMotionHintFromConnections", () => {
5
+ it("returns empty string for unknown motion", () => {
6
+ expect(composeCameraMotionHintFromConnections("nonexistent", [], [])).toBe("")
7
+ })
8
+
9
+ it("returns empty string when motion id is undefined", () => {
10
+ expect(composeCameraMotionHintFromConnections(undefined, [], [])).toBe("")
11
+ })
12
+
13
+ it("returns empty string when motion id resolves to empty hint (e.g. auto)", () => {
14
+ expect(composeCameraMotionHintFromConnections("auto", [], [])).toBe("")
15
+ })
16
+
17
+ it("returns bare motion hint when no connections", () => {
18
+ const out = composeCameraMotionHintFromConnections("orbit-right", [], [])
19
+ expect(out).toContain("orbit")
20
+ expect(out).not.toContain("beginning")
21
+ expect(out).not.toContain("ending")
22
+ })
23
+
24
+ it("appends 'beginning with' clause when only start nodes connected", () => {
25
+ const out = composeCameraMotionHintFromConnections(
26
+ "orbit-right",
27
+ ["wide shot of subject", "soft daylight"],
28
+ [],
29
+ )
30
+ expect(out).toContain("beginning with wide shot of subject and soft daylight")
31
+ expect(out).not.toContain("ending")
32
+ })
33
+
34
+ it("appends 'ending with' clause when only end nodes connected", () => {
35
+ const out = composeCameraMotionHintFromConnections("orbit-right", [], ["close-up on face"])
36
+ expect(out).toContain("ending with close-up on face")
37
+ expect(out).not.toContain("beginning")
38
+ })
39
+
40
+ it("emits both clauses when both ends connected", () => {
41
+ const out = composeCameraMotionHintFromConnections(
42
+ "orbit-right",
43
+ ["wide shot"],
44
+ ["close-up"],
45
+ )
46
+ expect(out).toContain("beginning with wide shot")
47
+ expect(out).toContain("ending with close-up")
48
+ })
49
+
50
+ it("ignores empty hints", () => {
51
+ const out = composeCameraMotionHintFromConnections(
52
+ "orbit-right",
53
+ ["", "wide shot", ""],
54
+ [],
55
+ )
56
+ expect(out).toContain("beginning with wide shot")
57
+ // No double separators left from filtered-out empty entries.
58
+ expect(out).not.toContain(" and ,")
59
+ })
60
+
61
+ it("works with static motion (no per-motion gating)", () => {
62
+ const out = composeCameraMotionHintFromConnections(
63
+ "static",
64
+ ["wide shot"],
65
+ ["close-up"],
66
+ )
67
+ expect(out).toContain("static")
68
+ expect(out).toContain("beginning with wide shot")
69
+ expect(out).toContain("ending with close-up")
70
+ })
71
+ })