@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,71 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildMultiPickerAnalyzerSpec, buildPickerAnalyzerSpec, pickerFanoutTargets } from "../index.js"
3
+
4
+ describe("buildMultiPickerAnalyzerSpec", () => {
5
+ it("composes one section per wired picker plus gaps, order-independent", () => {
6
+ const a = buildMultiPickerAnalyzerSpec(["styling", "person"])
7
+ const b = buildMultiPickerAnalyzerSpec(["person", "styling"])
8
+ expect(a.toolName).toBe("emit_pickers")
9
+ const parsed = a.schema.parse({ person: { type: "stylish-influencer" }, styling: { makeup: "makeup-natural" } })
10
+ expect(parsed).toMatchObject({ person: { type: "stylish-influencer" } })
11
+ expect(a.legend).toBe(b.legend) // sorted → deterministic
12
+ })
13
+
14
+ it("defaults gaps to empty and accepts a valid gaps payload", () => {
15
+ const { schema } = buildMultiPickerAnalyzerSpec(["lens"])
16
+ expect(schema.parse({ lens: { lens: "normal-50mm" } })).toMatchObject({
17
+ gaps: { missingItems: [], missingCategories: [] },
18
+ })
19
+ const withGaps = schema.parse({
20
+ lens: { lens: "normal-50mm" },
21
+ gaps: { missingItems: [{ picker: "lens", dimension: "lens", observed: "tilt-shift macro hybrid" }] },
22
+ })
23
+ expect(withGaps.gaps.missingItems).toHaveLength(1)
24
+ })
25
+
26
+ it("FUZZ: arbitrary gaps content never alters the enum-validated picker sections", () => {
27
+ const { schema } = buildMultiPickerAnalyzerSpec(["person"])
28
+ const personSpec = buildPickerAnalyzerSpec("person")
29
+ const validPerson = { type: "stylish-influencer", age: "age-early-20s" }
30
+ const garbage = [
31
+ { missingItems: Array.from({ length: 50 }, () => ({ picker: "x", dimension: "y", observed: "z".repeat(500) })) },
32
+ { missingItems: [{ picker: 123, dimension: null, observed: {} }] },
33
+ "not even an object",
34
+ { unknownKey: true },
35
+ ]
36
+ for (const g of garbage) {
37
+ const result = schema.safeParse({ person: validPerson, gaps: g })
38
+ // Either the whole payload is rejected (gaps invalid) OR it parses with
39
+ // the person section intact — never a corrupted person section.
40
+ if (result.success) {
41
+ expect(result.data.person).toEqual(validPerson)
42
+ // when gaps parses at all it is within the 8-item cap (over-cap or
43
+ // over-length payloads like the 50-item one are REJECTED outright by
44
+ // .max(), never truncated — so they take the !success path above)
45
+ expect(result.data.gaps.missingItems.length).toBeLessThanOrEqual(8)
46
+ }
47
+ }
48
+ // The person section schema itself rejects an out-of-enum value regardless
49
+ expect(personSpec.dimensions.length).toBeGreaterThan(0)
50
+ })
51
+ })
52
+
53
+ describe("pickerFanoutTargets", () => {
54
+ const nodes = [
55
+ { id: "dp", type: "describe-to-picker" },
56
+ { id: "p", type: "person" },
57
+ { id: "s", type: "styling" },
58
+ { id: "t", type: "combine-text" },
59
+ ]
60
+ const edges = [
61
+ { source: "dp", target: "p", sourceHandle: "picker-json" },
62
+ { source: "dp", target: "s", sourceHandle: "picker-json" },
63
+ { source: "dp", target: "t", sourceHandle: "in" }, // non-picker target ignored
64
+ ]
65
+ it("derives only analyzable picker-json targets, deduped", () => {
66
+ expect(pickerFanoutTargets("dp", edges, nodes).sort()).toEqual(["person", "styling"])
67
+ })
68
+ it("ignores non-picker-json source handles", () => {
69
+ expect(pickerFanoutTargets("dp", [{ source: "dp", target: "p", sourceHandle: "out" }], nodes)).toEqual([])
70
+ })
71
+ })
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ MUSIC_GENRES,
4
+ MUSIC_ERAS,
5
+ getMusicGenre,
6
+ getMusicGenreLabel,
7
+ getMusicSubgenre,
8
+ getMusicEra,
9
+ buildMusicGenreHints,
10
+ MUSIC_GENRE_DEFAULT_DATA,
11
+ } from "../music-genre.js"
12
+
13
+ describe("MUSIC_GENRES catalog", () => {
14
+ it("is non-empty and has unique ids", () => {
15
+ expect(MUSIC_GENRES.length).toBeGreaterThan(0)
16
+ const ids = new Set(MUSIC_GENRES.map((g) => g.id))
17
+ expect(ids.size).toBe(MUSIC_GENRES.length)
18
+ })
19
+
20
+ it("every genre has a non-empty promptHint", () => {
21
+ for (const g of MUSIC_GENRES) expect(g.promptHint.length).toBeGreaterThan(0)
22
+ })
23
+
24
+ it("every subgenre has a non-empty promptHint", () => {
25
+ for (const g of MUSIC_GENRES) {
26
+ for (const s of g.subgenres) expect(s.promptHint.length).toBeGreaterThan(0)
27
+ }
28
+ })
29
+ })
30
+
31
+ describe("MUSIC_ERAS catalog", () => {
32
+ it("is non-empty and has unique ids", () => {
33
+ expect(MUSIC_ERAS.length).toBeGreaterThan(0)
34
+ const ids = new Set(MUSIC_ERAS.map((e) => e.id))
35
+ expect(ids.size).toBe(MUSIC_ERAS.length)
36
+ })
37
+ })
38
+
39
+ describe("lookup helpers", () => {
40
+ it("getMusicGenre resolves a known id", () => {
41
+ expect(getMusicGenre(MUSIC_GENRES[0].id)).toBe(MUSIC_GENRES[0])
42
+ })
43
+
44
+ it("getMusicGenre returns undefined for unknown / empty", () => {
45
+ expect(getMusicGenre(undefined)).toBeUndefined()
46
+ expect(getMusicGenre("")).toBeUndefined()
47
+ expect(getMusicGenre("not-a-real-genre")).toBeUndefined()
48
+ })
49
+
50
+ it("getMusicGenreLabel falls back to id when not found", () => {
51
+ expect(getMusicGenreLabel("not-a-real-genre")).toBe("not-a-real-genre")
52
+ expect(getMusicGenreLabel(undefined)).toBe("")
53
+ })
54
+
55
+ it("getMusicSubgenre resolves a nested subgenre", () => {
56
+ const g = MUSIC_GENRES.find((x) => x.subgenres.length > 0)!
57
+ const s = g.subgenres[0]
58
+ expect(getMusicSubgenre(g.id, s.id)?.id).toBe(s.id)
59
+ })
60
+
61
+ it("getMusicEra resolves a known era", () => {
62
+ expect(getMusicEra(MUSIC_ERAS[0].id)?.id).toBe(MUSIC_ERAS[0].id)
63
+ })
64
+ })
65
+
66
+ describe("buildMusicGenreHints", () => {
67
+ it("returns empty string for empty data", () => {
68
+ expect(buildMusicGenreHints({})).toBe("")
69
+ })
70
+
71
+ it("returns just the genre hint when only genre is set", () => {
72
+ const g = MUSIC_GENRES[0]
73
+ expect(buildMusicGenreHints({ genre: g.id })).toBe(g.promptHint)
74
+ })
75
+
76
+ it("composes era + subgenre + genre in that order", () => {
77
+ const g = MUSIC_GENRES.find((x) => x.subgenres.length > 0)!
78
+ const s = g.subgenres[0]
79
+ const e = MUSIC_ERAS[0]
80
+ const out = buildMusicGenreHints({ genre: g.id, subgenre: s.id, era: e.id })
81
+ expect(out).toContain(e.promptHint)
82
+ expect(out).toContain(s.promptHint)
83
+ expect(out.indexOf(e.promptHint)).toBeLessThan(out.indexOf(s.promptHint))
84
+ })
85
+
86
+ it("uses base genre hint when subgenre is unset", () => {
87
+ const g = MUSIC_GENRES[0]
88
+ const e = MUSIC_ERAS[0]
89
+ const out = buildMusicGenreHints({ genre: g.id, era: e.id })
90
+ expect(out).toContain(e.promptHint)
91
+ expect(out).toContain(g.promptHint)
92
+ })
93
+
94
+ it("falls back gracefully on unknown ids", () => {
95
+ expect(buildMusicGenreHints({ genre: "not-real" })).toBe("")
96
+ })
97
+ })
98
+
99
+ describe("MUSIC_GENRE_DEFAULT_DATA", () => {
100
+ it("is empty (forces user to pick)", () => {
101
+ expect(MUSIC_GENRE_DEFAULT_DATA).toEqual({})
102
+ })
103
+ })
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ MUSIC_ENERGIES,
4
+ MUSIC_EMOTIONS,
5
+ MUSIC_VIBES,
6
+ getMusicEnergy,
7
+ getMusicEmotion,
8
+ getMusicVibe,
9
+ buildMusicMoodHints,
10
+ MUSIC_MOOD_DEFAULT_DATA,
11
+ } from "../music-mood.js"
12
+
13
+ describe("MUSIC_ENERGIES / EMOTIONS / VIBES catalogs", () => {
14
+ it("each is non-empty with unique ids", () => {
15
+ for (const list of [MUSIC_ENERGIES, MUSIC_EMOTIONS, MUSIC_VIBES]) {
16
+ expect(list.length).toBeGreaterThan(0)
17
+ const ids = new Set(list.map((x) => x.id))
18
+ expect(ids.size).toBe(list.length)
19
+ }
20
+ })
21
+
22
+ it("every entry has a non-empty promptHint", () => {
23
+ for (const list of [MUSIC_ENERGIES, MUSIC_EMOTIONS, MUSIC_VIBES]) {
24
+ for (const x of list) expect(x.promptHint.length).toBeGreaterThan(0)
25
+ }
26
+ })
27
+
28
+ it("MUSIC_VIBES includes the four dark-thriller entries", () => {
29
+ const ids = new Set(MUSIC_VIBES.map((x) => x.id))
30
+ for (const id of ["suspenseful", "espionage", "cold", "clandestine"]) {
31
+ expect(ids.has(id), `MUSIC_VIBES missing "${id}"`).toBe(true)
32
+ }
33
+ })
34
+ })
35
+
36
+ describe("buildMusicMoodHints", () => {
37
+ it("returns empty for empty data", () => {
38
+ expect(buildMusicMoodHints({})).toBe("")
39
+ })
40
+
41
+ it("returns single field hint when one is set", () => {
42
+ const e = MUSIC_ENERGIES[0]
43
+ expect(buildMusicMoodHints({ energy: e.id })).toBe(e.promptHint)
44
+ })
45
+
46
+ it("composes [energy] [emotion] [vibe] in order", () => {
47
+ const energy = MUSIC_ENERGIES[0]
48
+ const emotion = MUSIC_EMOTIONS[0]
49
+ const vibe = MUSIC_VIBES[0]
50
+ const out = buildMusicMoodHints({
51
+ energy: energy.id, emotion: emotion.id, vibe: vibe.id,
52
+ })
53
+ expect(out.indexOf(energy.promptHint)).toBeLessThan(out.indexOf(emotion.promptHint))
54
+ expect(out.indexOf(emotion.promptHint)).toBeLessThan(out.indexOf(vibe.promptHint))
55
+ })
56
+
57
+ it("falls back gracefully on unknown ids", () => {
58
+ expect(buildMusicMoodHints({ energy: "not-real" })).toBe("")
59
+ })
60
+
61
+ it("accepts emotion as a string array and joins hints", () => {
62
+ const [e1, e2, e3] = MUSIC_EMOTIONS
63
+ const out = buildMusicMoodHints({ emotion: [e1.id, e2.id, e3.id] })
64
+ expect(out).toContain(e1.promptHint)
65
+ expect(out).toContain(e2.promptHint)
66
+ expect(out).toContain(e3.promptHint)
67
+ })
68
+
69
+ it("accepts vibe as a string array and joins hints", () => {
70
+ const [v1, v2] = MUSIC_VIBES
71
+ const out = buildMusicMoodHints({ vibe: [v1.id, v2.id] })
72
+ expect(out).toContain(v1.promptHint)
73
+ expect(out).toContain(v2.promptHint)
74
+ })
75
+
76
+ it("preserves existing single-string emotion/vibe (back-compat)", () => {
77
+ const emotion = MUSIC_EMOTIONS[0]
78
+ const vibe = MUSIC_VIBES[0]
79
+ const out = buildMusicMoodHints({ emotion: emotion.id, vibe: vibe.id })
80
+ expect(out).toContain(emotion.promptHint)
81
+ expect(out).toContain(vibe.promptHint)
82
+ })
83
+ })
84
+
85
+ describe("MUSIC_MOOD_DEFAULT_DATA", () => {
86
+ it("is empty", () => { expect(MUSIC_MOOD_DEFAULT_DATA).toEqual({}) })
87
+ })
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildImagePrompt } from "../prompt-builder.js"
3
+ import type { ConnectedReference } from "@nodaro/shared"
4
+
5
+ // Minimal wired refs — only id/defaultName/source/url are required on
6
+ // ConnectedReference; everything else is optional and irrelevant to the
7
+ // canonical hybrid render (it reads source + url + defaultUsageMode +
8
+ // elementInjection + identityLock).
9
+ const sword: ConnectedReference = {
10
+ id: "o", defaultName: "Sword", source: "wired-object", url: "https://cdn/sword.png",
11
+ }
12
+ const dragon: ConnectedReference = {
13
+ id: "c", defaultName: "Dragon", source: "wired-creature", url: "https://cdn/dragon.png",
14
+ }
15
+
16
+ const occurrences = (haystack: string, needle: string): number =>
17
+ haystack.split(needle).length - 1
18
+
19
+ describe("object/creature reference converges onto the image hybrid form", () => {
20
+ it("wired object (no token) → 'the object from reference image A', no legacy wrap", () => {
21
+ const out = buildImagePrompt({
22
+ prompt: "a hero on a battlefield", connectedReferences: [sword],
23
+ provider: "nano-banana-pro", referenceFormat: "hybrid",
24
+ })
25
+ expect(out.prompt).toContain("the object from reference image A")
26
+ expect(out.prompt).not.toContain("Use these references")
27
+ expect(out.referenceImageUrls).toContain("https://cdn/sword.png")
28
+ })
29
+
30
+ it("wired creature (no token) → 'the creature from reference image A'", () => {
31
+ const out = buildImagePrompt({
32
+ prompt: "a knight rides into battle", connectedReferences: [dragon],
33
+ provider: "nano-banana-pro", referenceFormat: "hybrid",
34
+ })
35
+ expect(out.prompt).toContain("the creature from reference image A")
36
+ expect(out.referenceImageUrls).toContain("https://cdn/dragon.png")
37
+ })
38
+
39
+ it("object referenced via {image:1:object} token renders ONCE (coveredUrls guard)", () => {
40
+ const out = buildImagePrompt({
41
+ prompt: "a hero holding {image:1:object}", connectedReferences: [sword],
42
+ provider: "nano-banana-pro", referenceFormat: "hybrid",
43
+ })
44
+ // The token expands inline; the canonical render must NOT ALSO emit the
45
+ // same phrase as a trailing directive (double-emit).
46
+ expect(occurrences(out.prompt, "the object from reference image A")).toBe(1)
47
+ // And exactly one reference to slot A overall.
48
+ expect(occurrences(out.prompt, "reference image A")).toBe(1)
49
+ })
50
+
51
+ it("creature opt-in identity lock surfaces a lock line; default OFF emits none; object has no default lock", () => {
52
+ // Creature opt-in, built-in wording (no custom text).
53
+ const lockedCreature = buildImagePrompt({
54
+ prompt: "a knight rides into battle",
55
+ connectedReferences: [{ ...dragon, identityLock: { enabled: true } } as ConnectedReference],
56
+ provider: "nano-banana-pro", referenceFormat: "hybrid",
57
+ })
58
+ expect(lockedCreature.prompt).toContain("the creature from reference image A")
59
+ expect(lockedCreature.prompt).toContain("Lock the exact identity of the creature in reference image A")
60
+
61
+ // Creature default (no identityLock) → no lock line.
62
+ const unlockedCreature = buildImagePrompt({
63
+ prompt: "a knight rides into battle", connectedReferences: [dragon],
64
+ provider: "nano-banana-pro", referenceFormat: "hybrid",
65
+ })
66
+ expect(unlockedCreature.prompt).not.toContain("Lock the exact identity of the creature")
67
+
68
+ // Object opt-in enabled but NO custom text → NO lock line (object has no
69
+ // built-in wording); the phrase still renders.
70
+ const objectEnabledNoText = buildImagePrompt({
71
+ prompt: "a hero on a battlefield",
72
+ connectedReferences: [{ ...sword, identityLock: { enabled: true } } as ConnectedReference],
73
+ provider: "nano-banana-pro", referenceFormat: "hybrid",
74
+ })
75
+ expect(objectEnabledNoText.prompt).toContain("the object from reference image A")
76
+ expect(objectEnabledNoText.prompt).not.toContain("Lock the exact")
77
+ })
78
+
79
+ it("LEGACY (no referenceFormat) unchanged → still the block, not the hybrid phrase", () => {
80
+ const out = buildImagePrompt({
81
+ prompt: "a hero on a battlefield", connectedReferences: [sword], provider: "nano-banana-pro",
82
+ })
83
+ expect(out.prompt).not.toContain("the object from reference image A")
84
+ })
85
+ })
86
+
87
+ describe("location coveredUrls guard (C1 review Minor regression)", () => {
88
+ const library: ConnectedReference = {
89
+ id: "l", defaultName: "Old Library", source: "wired-location",
90
+ url: "https://cdn/library.png", locationSlug: "old-library",
91
+ }
92
+
93
+ it("location BOTH unmentioned AND {image:N}-tokened → renders ONCE (not also canonical)", () => {
94
+ const out = buildImagePrompt({
95
+ prompt: "a chase {image:1:location}", connectedReferences: [library],
96
+ provider: "nano-banana-pro", referenceFormat: "hybrid",
97
+ })
98
+ // Token expands inline to "the location from reference image A"; the
99
+ // canonical render must be suppressed (else "the background from reference
100
+ // image A" would ALSO appear → slot A referenced twice).
101
+ expect(out.prompt).toContain("the location from reference image A")
102
+ expect(out.prompt).not.toContain("the background from reference image A")
103
+ expect(occurrences(out.prompt, "reference image A")).toBe(1)
104
+ })
105
+ })
@@ -0,0 +1,270 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { getParameterPromptHint } from "../parameter-prompt-hint.js"
3
+ import { PARAMETER_NODE_TYPES, getParameterValue } from "@nodaro/shared"
4
+ import { ACTION_FX } from "../action-fx.js"
5
+
6
+ describe("getParameterPromptHint — action-fx", () => {
7
+ it("returns the catalog hint for a single id", () => {
8
+ const first = ACTION_FX[0]
9
+ const result = getParameterPromptHint({ id: "n1", type: "action-fx", data: { actionFx: first.id } })
10
+ expect(result).toBe(first.promptHint)
11
+ })
12
+
13
+ it("returns comma-joined hints for two ids", () => {
14
+ const a = ACTION_FX[0]
15
+ const b = ACTION_FX[1]
16
+ const result = getParameterPromptHint({ id: "n1", type: "action-fx", data: { actionFx: [a.id, b.id] } })
17
+ expect(result).toBe(`${a.promptHint}, ${b.promptHint}`)
18
+ })
19
+
20
+ it("returns empty string when no actionFx is set", () => {
21
+ const result = getParameterPromptHint({ id: "n1", type: "action-fx", data: {} })
22
+ expect(result).toBe("")
23
+ })
24
+ })
25
+
26
+ describe("getParameterPromptHint — Sound parameter nodes", () => {
27
+ it("dispatches music-genre to buildMusicGenreHints", () => {
28
+ const node = { id: "n1", type: "music-genre", data: { genre: "electronic", subgenre: "synthwave", era: "1980s" } }
29
+ const out = getParameterPromptHint(node)
30
+ expect(out).toContain("1980s")
31
+ expect(out).toContain("synthwave")
32
+ })
33
+
34
+ it("dispatches music-mood to buildMusicMoodHints", () => {
35
+ const node = { id: "n1", type: "music-mood", data: { energy: "high", emotion: "triumphant", vibe: "cinematic" } }
36
+ expect(getParameterPromptHint(node)).toContain("triumphant")
37
+ })
38
+
39
+ it("dispatches instrumentation to buildInstrumentationHints", () => {
40
+ const node = { id: "n1", type: "instrumentation", data: { instruments: ["piano"], production: "polished" } }
41
+ expect(getParameterPromptHint(node)).toContain("piano")
42
+ })
43
+
44
+ it("dispatches voice-character to buildVoiceCharacterHints", () => {
45
+ const node = { id: "n1", type: "voice-character", data: { age: "middle-aged", gender: "male", timbre: "warm" } }
46
+ expect(getParameterPromptHint(node)).toContain("warm")
47
+ })
48
+
49
+ it("dispatches voice-delivery to buildVoiceDeliveryHints", () => {
50
+ const node = { id: "n1", type: "voice-delivery", data: { pace: "measured", emotion: "reassuring" } }
51
+ expect(getParameterPromptHint(node)).toContain("reassuring")
52
+ })
53
+
54
+ it("returns empty string for sound nodes with empty data", () => {
55
+ expect(getParameterPromptHint({ id: "n1", type: "music-genre", data: {} })).toBe("")
56
+ expect(getParameterPromptHint({ id: "n1", type: "voice-character", data: {} })).toBe("")
57
+ })
58
+ })
59
+
60
+ describe("PARAMETER_NODE_TYPES — Sound nodes", () => {
61
+ it("includes all 5 new sound types", () => {
62
+ for (const t of ["music-genre", "music-mood", "instrumentation", "voice-character", "voice-delivery"]) {
63
+ expect(PARAMETER_NODE_TYPES.has(t)).toBe(true)
64
+ }
65
+ })
66
+ })
67
+
68
+ describe("getParameterValue — Sound nodes", () => {
69
+ it("returns first set sub-field per node", () => {
70
+ expect(getParameterValue({ subgenre: "synthwave", genre: "electronic" }, "music-genre")).toBe("synthwave")
71
+ expect(getParameterValue({ genre: "rock" }, "music-genre")).toBe("rock")
72
+ expect(getParameterValue({ emotion: "happy", energy: "high" }, "music-mood")).toBe("happy")
73
+ expect(getParameterValue({ instruments: ["piano"] }, "instrumentation")).toBe("piano")
74
+ expect(getParameterValue({ timbre: "warm" }, "voice-character")).toBe("warm")
75
+ expect(getParameterValue({ archetype: "mentor" }, "voice-delivery")).toBe("mentor")
76
+ })
77
+ })
78
+
79
+ describe("getParameterPromptHint — transition", () => {
80
+ it("returns bare hint when no context, no timing, no connections", () => {
81
+ const r = getParameterPromptHint({
82
+ id: "n1",
83
+ type: "transition",
84
+ data: { transition: "cross-dissolve" },
85
+ } as any)
86
+ expect(r.length).toBeGreaterThan(0)
87
+ expect(r).toContain("cross-dissolve")
88
+ })
89
+
90
+ it("returns empty for 'auto' / empty string", () => {
91
+ expect(getParameterPromptHint({ id: "n1", type: "transition", data: { transition: "auto" } } as any)).toBe("")
92
+ expect(getParameterPromptHint({ id: "n1", type: "transition", data: { transition: "" } } as any)).toBe("")
93
+ })
94
+
95
+ it("composes timing clauses from data fields", () => {
96
+ const r = getParameterPromptHint({
97
+ id: "n1",
98
+ type: "transition",
99
+ data: { transition: "cross-dissolve", position: "end", duration: "medium" },
100
+ } as any)
101
+ expect(r).toContain("the transition occurs at the end of the clip")
102
+ expect(r).toContain("lasting approximately 2 seconds")
103
+ })
104
+
105
+ it("multi-pick array data shape works", () => {
106
+ const r = getParameterPromptHint({
107
+ id: "n1",
108
+ type: "transition",
109
+ data: { transition: ["smash-cut", "white-flash"] },
110
+ } as any)
111
+ expect(r).toMatch(/smash/i)
112
+ expect(r).toMatch(/flash/i)
113
+ })
114
+
115
+ it("composes start/end from graph context with startState/endState edges", () => {
116
+ const ctx = {
117
+ nodes: [
118
+ { id: "n1", type: "transition", data: { transition: "fast-forward-day-night" } },
119
+ { id: "n2", type: "tone", data: { tone: "warm golden morning light" } },
120
+ { id: "n3", type: "tone", data: { tone: "deep blue moonlit night" } },
121
+ ],
122
+ edges: [
123
+ { source: "n2", target: "n1", targetHandle: "startState", sourceHandle: "out" },
124
+ { source: "n3", target: "n1", targetHandle: "endState", sourceHandle: "out" },
125
+ ],
126
+ }
127
+ const r = getParameterPromptHint(ctx.nodes[0] as any, ctx as any)
128
+ expect(r).toMatch(/starting from .+morning/i)
129
+ expect(r).toMatch(/ending at .+night/)
130
+ })
131
+ })
132
+
133
+ describe("getParameterPromptHint — character-fx", () => {
134
+ it("returns bare hint with no target / no timing / no ctx", () => {
135
+ const r = getParameterPromptHint({
136
+ id: "n1",
137
+ type: "character-fx",
138
+ data: { characterFx: "werewolf" },
139
+ } as any)
140
+ expect(r).toContain("the subject")
141
+ expect(r).toContain("werewolf")
142
+ })
143
+
144
+ it("substitutes target name from upstream character ref via 'target' handle", () => {
145
+ const ctx = {
146
+ nodes: [
147
+ { id: "n1", type: "character-fx", data: { characterFx: "werewolf" } },
148
+ { id: "n2", type: "character", data: { characterName: "Aria Voss" } },
149
+ ],
150
+ edges: [
151
+ { source: "n2", target: "n1", targetHandle: "target", sourceHandle: "characterRef" },
152
+ ],
153
+ }
154
+ const r = getParameterPromptHint(ctx.nodes[0] as any, ctx as any)
155
+ expect(r).toContain("Aria Voss")
156
+ expect(r).not.toContain("the subject")
157
+ })
158
+
159
+ it("multi-pick array works with target substitution", () => {
160
+ const ctx = {
161
+ nodes: [
162
+ { id: "n1", type: "character-fx", data: { characterFx: ["werewolf", "fire-breathe"] } },
163
+ { id: "n2", type: "character", data: { characterName: "Aria" } },
164
+ ],
165
+ edges: [
166
+ { source: "n2", target: "n1", targetHandle: "target", sourceHandle: "characterRef" },
167
+ ],
168
+ }
169
+ const r = getParameterPromptHint(ctx.nodes[0] as any, ctx as any)
170
+ expect(r).toContain("Aria")
171
+ expect(r).toContain(", and ")
172
+ expect(r).not.toContain("the subject")
173
+ })
174
+
175
+ it("ignores edges not on 'target' handle", () => {
176
+ const ctx = {
177
+ nodes: [
178
+ { id: "n1", type: "character-fx", data: { characterFx: "werewolf" } },
179
+ { id: "n2", type: "character", data: { characterName: "Ignored" } },
180
+ ],
181
+ edges: [
182
+ { source: "n2", target: "n1", targetHandle: "startState", sourceHandle: "characterRef" },
183
+ ],
184
+ }
185
+ const r = getParameterPromptHint(ctx.nodes[0] as any, ctx as any)
186
+ expect(r).not.toContain("Ignored")
187
+ expect(r).toContain("the subject") // un-substituted
188
+ })
189
+
190
+ it("falls through field candidates (faceName, objectName, locationName)", () => {
191
+ for (const [field, name] of [
192
+ ["faceName", "Some Face"],
193
+ ["objectName", "An Object"],
194
+ ["locationName", "Some Place"],
195
+ ] as const) {
196
+ const ctx = {
197
+ nodes: [
198
+ { id: "n1", type: "character-fx", data: { characterFx: "werewolf" } },
199
+ { id: "n2", type: "face", data: { [field]: name } },
200
+ ],
201
+ edges: [{ source: "n2", target: "n1", targetHandle: "target", sourceHandle: "out" }],
202
+ }
203
+ const r = getParameterPromptHint(ctx.nodes[0] as any, ctx as any)
204
+ expect(r).toContain(name)
205
+ }
206
+ })
207
+ })
208
+
209
+ describe("getParameterPromptHint — furniture", () => {
210
+ it("returns a prompt fragment with the furniture label + description", () => {
211
+ const hint = getParameterPromptHint({ id: "n1", type: "furniture", data: { furniture: "sofa" } })
212
+ expect(hint).toMatch(/including a .*/)
213
+ expect(hint.length).toBeGreaterThan(0)
214
+ })
215
+
216
+ it("returns empty string for unknown furniture id", () => {
217
+ const hint = getParameterPromptHint({ id: "n1", type: "furniture", data: { furniture: "nonexistent-furniture" } })
218
+ expect(hint).toBe("")
219
+ })
220
+
221
+ it("returns empty string when furniture field is missing", () => {
222
+ const hint = getParameterPromptHint({ id: "n1", type: "furniture", data: {} })
223
+ expect(hint).toBe("")
224
+ })
225
+ })
226
+
227
+ // Regression: the graph-aware nodes (camera-motion / transition / character-fx)
228
+ // branch before the main switch and must STILL honor the user's preText/postText
229
+ // (the config-panel preview promises they're injected). They previously returned
230
+ // the bare compose* result and dropped custom text at execution.
231
+ describe("getParameterPromptHint — preText/postText for graph-aware nodes", () => {
232
+ it("camera-motion wraps preText/postText around the motion clause", () => {
233
+ const out = getParameterPromptHint({
234
+ id: "n1",
235
+ type: "camera-motion",
236
+ data: { cameraMotion: "dolly-in", preText: "BEFORE", postText: "AFTER" },
237
+ })
238
+ expect(out.startsWith("BEFORE")).toBe(true)
239
+ expect(out.endsWith("AFTER")).toBe(true)
240
+ })
241
+
242
+ it("camera-motion still injects custom text when no motion is set (empty base)", () => {
243
+ const out = getParameterPromptHint({
244
+ id: "n1",
245
+ type: "camera-motion",
246
+ data: { preText: "ONLY PRE" },
247
+ })
248
+ expect(out).toContain("ONLY PRE")
249
+ })
250
+
251
+ it("transition includes preText and postText", () => {
252
+ const out = getParameterPromptHint({
253
+ id: "n1",
254
+ type: "transition",
255
+ data: { transition: "crossfade", preText: "PRE_T", postText: "POST_T" },
256
+ })
257
+ expect(out).toContain("PRE_T")
258
+ expect(out).toContain("POST_T")
259
+ })
260
+
261
+ it("character-fx includes preText and postText", () => {
262
+ const out = getParameterPromptHint({
263
+ id: "n1",
264
+ type: "character-fx",
265
+ data: { characterFx: "glow", preText: "PRE_FX", postText: "POST_FX" },
266
+ })
267
+ expect(out).toContain("PRE_FX")
268
+ expect(out).toContain("POST_FX")
269
+ })
270
+ })