@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
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@nodaro/prompts",
3
+ "version": "1.0.1",
4
+ "description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
5
+ "type": "module",
6
+ "license": "FSL-1.1-Apache-2.0",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "dev": "tsup --watch",
20
+ "test": "vitest run"
21
+ },
22
+ "dependencies": {
23
+ "@nodaro/shared": "*"
24
+ },
25
+ "devDependencies": {
26
+ "tsup": "^8.5.0",
27
+ "typescript": "^5.7.3",
28
+ "vitest": "^4.1.2"
29
+ },
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/nodaroai/app.nodaro.ai.git",
36
+ "directory": "packages/prompts"
37
+ },
38
+ "homepage": "https://github.com/nodaroai/app.nodaro.ai/tree/main/packages/prompts#readme",
39
+ "bugs": "https://github.com/nodaroai/app.nodaro.ai/issues",
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "README.md",
46
+ "src",
47
+ "LICENSE"
48
+ ]
49
+ }
@@ -0,0 +1,65 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`buildImagePrompt characterization (pre/post segment refactor) > produces stable output across the fixture matrix > connected references directives 1`] = `
4
+ {
5
+ "nativeNegativePrompt": undefined,
6
+ "prompt": "Use these references for the output image:
7
+ - Image 1 (background — a stone castle) — use as the background/setting.
8
+
9
+ Compose them naturally into a single image: a knight at Image 1 (background)",
10
+ "referenceImageUrls": [
11
+ "https://example.com/castle.png",
12
+ ],
13
+ }
14
+ `;
15
+
16
+ exports[`buildImagePrompt characterization (pre/post segment refactor) > produces stable output across the fixture matrix > empty prompt + style 1`] = `
17
+ {
18
+ "nativeNegativePrompt": undefined,
19
+ "prompt": "
20
+ Style: film noir style, high-contrast black-and-white imagery, deep shadows, venetian-blind lighting and moody 1940s cinema feel",
21
+ "referenceImageUrls": undefined,
22
+ }
23
+ `;
24
+
25
+ exports[`buildImagePrompt characterization (pre/post segment refactor) > produces stable output across the fixture matrix > negative appended as Avoid (no native field) 1`] = `
26
+ {
27
+ "nativeNegativePrompt": undefined,
28
+ "prompt": "a knight
29
+ Avoid: blurry, watermark",
30
+ "referenceImageUrls": undefined,
31
+ }
32
+ `;
33
+
34
+ exports[`buildImagePrompt characterization (pre/post segment refactor) > produces stable output across the fixture matrix > negative native (imagen4) 1`] = `
35
+ {
36
+ "nativeNegativePrompt": "blurry",
37
+ "prompt": "a knight",
38
+ "referenceImageUrls": undefined,
39
+ }
40
+ `;
41
+
42
+ exports[`buildImagePrompt characterization (pre/post segment refactor) > produces stable output across the fixture matrix > plain prompt 1`] = `
43
+ {
44
+ "nativeNegativePrompt": undefined,
45
+ "prompt": "a knight on a cliff",
46
+ "referenceImageUrls": undefined,
47
+ }
48
+ `;
49
+
50
+ exports[`buildImagePrompt characterization (pre/post segment refactor) > produces stable output across the fixture matrix > style appended 1`] = `
51
+ {
52
+ "nativeNegativePrompt": undefined,
53
+ "prompt": "a knight
54
+ Style: cinematic film style, dramatic lighting with cinematic color grading, widescreen aesthetic and film-like depth of field",
55
+ "referenceImageUrls": undefined,
56
+ }
57
+ `;
58
+
59
+ exports[`buildImagePrompt characterization (pre/post segment refactor) > produces stable output across the fixture matrix > truncation 1`] = `
60
+ {
61
+ "nativeNegativePrompt": undefined,
62
+ "prompt": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
63
+ "referenceImageUrls": undefined,
64
+ }
65
+ `;
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ ACTION_FX,
4
+ ACTION_FX_IDS,
5
+ ACTION_FX_CATEGORY_LABELS,
6
+ ACTION_FX_CATEGORY_ORDER,
7
+ getActionFx,
8
+ getActionFxLabel,
9
+ getActionFxPromptHint,
10
+ buildActionFxHints,
11
+ } from "../action-fx.js"
12
+ import type { ActionFxCategory } from "../action-fx.js"
13
+
14
+ const VALID_CATEGORIES: ReadonlyArray<ActionFxCategory> = [
15
+ "disaster", "fire-blasts", "electric", "combat", "sci-fi", "magic",
16
+ ]
17
+
18
+ describe("ACTION_FX catalog", () => {
19
+ it("has at least 60 entries", () => {
20
+ expect(ACTION_FX.length).toBeGreaterThanOrEqual(60)
21
+ })
22
+
23
+ it("every entry has all 5 fields populated and non-empty", () => {
24
+ for (const fx of ACTION_FX) {
25
+ expect(fx.id, `entry: ${JSON.stringify(fx)}`).toBeTruthy()
26
+ expect(fx.label, `id: ${fx.id}`).toBeTruthy()
27
+ expect(fx.category, `id: ${fx.id}`).toBeTruthy()
28
+ expect(fx.description, `id: ${fx.id}`).toBeTruthy()
29
+ expect(fx.promptHint, `id: ${fx.id}`).toBeTruthy()
30
+ expect(fx.promptHint.length, `id: ${fx.id}`).toBeGreaterThan(20)
31
+ }
32
+ })
33
+
34
+ it("every entry's category is a valid ActionFxCategory", () => {
35
+ for (const fx of ACTION_FX) {
36
+ expect(VALID_CATEGORIES, `id: ${fx.id}, category: ${fx.category}`).toContain(fx.category)
37
+ }
38
+ })
39
+
40
+ it("all ids are unique", () => {
41
+ const ids = ACTION_FX.map((fx) => fx.id)
42
+ expect(new Set(ids).size).toBe(ids.length)
43
+ })
44
+
45
+ it("ACTION_FX_IDS matches catalog order", () => {
46
+ expect(ACTION_FX_IDS).toEqual(ACTION_FX.map((fx) => fx.id))
47
+ })
48
+
49
+ it("ACTION_FX_CATEGORY_ORDER matches the expected order", () => {
50
+ expect(ACTION_FX_CATEGORY_ORDER).toEqual([
51
+ "disaster", "fire-blasts", "electric", "combat", "sci-fi", "magic",
52
+ ])
53
+ })
54
+
55
+ it("ACTION_FX_CATEGORY_LABELS has a label for every category", () => {
56
+ for (const cat of VALID_CATEGORIES) {
57
+ expect(ACTION_FX_CATEGORY_LABELS[cat]).toBeTruthy()
58
+ }
59
+ })
60
+
61
+ it("every category has at least one entry", () => {
62
+ const categoriesPresent = new Set(ACTION_FX.map((fx) => fx.category))
63
+ for (const cat of VALID_CATEGORIES) {
64
+ expect(categoriesPresent, `category ${cat} has no entries`).toContain(cat)
65
+ }
66
+ })
67
+ })
68
+
69
+ describe("getActionFx", () => {
70
+ it("returns the entry for a valid id", () => {
71
+ const first = ACTION_FX[0]
72
+ expect(getActionFx(first.id)).toEqual(first)
73
+ })
74
+
75
+ it("returns undefined for an unknown id", () => {
76
+ expect(getActionFx("not-a-real-id")).toBeUndefined()
77
+ })
78
+
79
+ it("returns undefined for null/undefined input", () => {
80
+ expect(getActionFx(null)).toBeUndefined()
81
+ expect(getActionFx(undefined)).toBeUndefined()
82
+ })
83
+ })
84
+
85
+ describe("getActionFxLabel", () => {
86
+ it("returns the label for a valid id", () => {
87
+ const first = ACTION_FX[0]
88
+ expect(getActionFxLabel(first.id)).toBe(first.label)
89
+ })
90
+
91
+ it("returns the fallback when id is unknown and fallback provided", () => {
92
+ expect(getActionFxLabel("nope", "Fallback Label")).toBe("Fallback Label")
93
+ })
94
+
95
+ it("returns titleized id when no fallback and unknown", () => {
96
+ expect(getActionFxLabel("hello-world")).toBe("Hello World")
97
+ })
98
+ })
99
+
100
+ describe("getActionFxPromptHint", () => {
101
+ it("returns the promptHint for a valid id", () => {
102
+ const first = ACTION_FX[0]
103
+ expect(getActionFxPromptHint(first.id)).toBe(first.promptHint)
104
+ })
105
+
106
+ it("returns empty string for unknown id", () => {
107
+ expect(getActionFxPromptHint("nope")).toBe("")
108
+ })
109
+ })
110
+
111
+ describe("buildActionFxHints", () => {
112
+ it("returns empty array for undefined", () => {
113
+ expect(buildActionFxHints(undefined)).toEqual([])
114
+ })
115
+
116
+ it("returns empty array for null", () => {
117
+ expect(buildActionFxHints(null)).toEqual([])
118
+ })
119
+
120
+ it("returns empty array for empty string", () => {
121
+ expect(buildActionFxHints("")).toEqual([])
122
+ })
123
+
124
+ it("returns single-element array for a known string id", () => {
125
+ const first = ACTION_FX[0]
126
+ expect(buildActionFxHints(first.id)).toEqual([first.promptHint])
127
+ })
128
+
129
+ it("returns empty array for an unknown string id", () => {
130
+ expect(buildActionFxHints("not-a-real-id")).toEqual([])
131
+ })
132
+
133
+ it("returns multiple promptHints in order for an array of ids", () => {
134
+ const first = ACTION_FX[0]
135
+ const last = ACTION_FX[ACTION_FX.length - 1]
136
+ expect(buildActionFxHints([first.id, last.id])).toEqual([first.promptHint, last.promptHint])
137
+ })
138
+
139
+ it("deduplicates repeated ids before applying the cap", () => {
140
+ const first = ACTION_FX[0]
141
+ const second = ACTION_FX[1]
142
+ expect(buildActionFxHints([first.id, first.id, second.id]))
143
+ .toEqual([first.promptHint, second.promptHint])
144
+ })
145
+
146
+ it("caps at 2 ids (drops the rest, enforced independently of the UI cap)", () => {
147
+ const ids = ACTION_FX.slice(0, 5).map((fx) => fx.id)
148
+ expect(buildActionFxHints(ids)).toHaveLength(2)
149
+ })
150
+
151
+ it("filters out unknown ids inside an array", () => {
152
+ const first = ACTION_FX[0]
153
+ expect(buildActionFxHints([first.id, "not-real"])).toEqual([first.promptHint])
154
+ })
155
+ })
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildPickerAnalyzerSpec, applyPickerJson } from "../picker-analyzer-registry.js"
3
+
4
+ const spec = buildPickerAnalyzerSpec("person")
5
+
6
+ describe("applyPickerJson", () => {
7
+ it("override: sets detected dims, clears undetected dimension fields, preserves preText/label", () => {
8
+ const current = { label: "P", type: "man", hairBase: "base-short-straight", preText: "keep me", customAge: 8, age: "age-custom" }
9
+ const patch = applyPickerJson(current, { type: "woman" }, "override", spec)
10
+ expect(patch.type).toBe("woman")
11
+ expect(patch.hairBase).toBeUndefined() // undetected → cleared
12
+ expect("preText" in patch).toBe(false) // never touched
13
+ expect("label" in patch).toBe(false)
14
+ expect(patch.customAge).toBeUndefined() // age dimension reset
15
+ expect(patch.lips).toBeUndefined() // legacy cleared in override
16
+ })
17
+
18
+ it("overwrite-detected: only writes detected dims, leaves others untouched", () => {
19
+ const current = { hairBase: "base-short-straight" }
20
+ const patch = applyPickerJson(current, { type: "woman" }, "overwrite-detected", spec)
21
+ expect(patch.type).toBe("woman")
22
+ expect("hairBase" in patch).toBe(false)
23
+ })
24
+
25
+ it("fill-empty: only fills currently-empty fields", () => {
26
+ const current = { type: "man" }
27
+ const patch = applyPickerJson(current, { type: "woman", frame: "frame-slim" }, "fill-empty", spec)
28
+ expect("type" in patch).toBe(false) // already set
29
+ expect(patch.frame).toBe("frame-slim")
30
+ })
31
+
32
+ it("coerces multi-limit dims to arrays and single to scalars", () => {
33
+ const patch = applyPickerJson({}, { ethnicity: ["east-asian", "mediterranean"], type: "man" }, "overwrite-detected", spec)
34
+ expect(Array.isArray(patch.ethnicity)).toBe(true)
35
+ expect(typeof patch.type).toBe("string")
36
+ })
37
+
38
+ // Cardinality-mismatch leniency: a misbehaving LLM may emit a bare string for
39
+ // a multi-limit dimension, or an array for a single-limit dimension. coerce()
40
+ // normalizes both shapes so the patch always matches the field's cardinality.
41
+ it("coerces a bare string into an array for a multi-limit dim (ethnicity, limit 2)", () => {
42
+ const patch = applyPickerJson({}, { ethnicity: "east-asian" }, "overwrite-detected", spec)
43
+ expect(patch.ethnicity).toEqual(["east-asian"])
44
+ })
45
+
46
+ it("coerces an array into the first-element scalar for a single-limit dim (type, limit 1)", () => {
47
+ const patch = applyPickerJson({}, { type: ["man", "woman"] }, "overwrite-detected", spec)
48
+ expect(patch.type).toBe("man")
49
+ })
50
+ })
@@ -0,0 +1,248 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { assembleImageInput } from "../assemble-image-input.js"
3
+ import { buildImagePrompt } from "../prompt-builder.js"
4
+ import { getFramingPromptHint } from "../framing.js"
5
+ import { getLightingPromptHint } from "../lighting.js"
6
+ import type { ConnectedReference } from "@nodaro/shared"
7
+
8
+ /**
9
+ * `assembleImageInput` is the keystone of removing the assembly mirror: the
10
+ * platform callers (execute-node / payload-builder) and Studio all route their
11
+ * `generate-image` assembly through it. These tests pin BOTH layers:
12
+ * (a) the id-based composition (direction / structured) — ported from
13
+ * Studio's `assembly.test.ts` as the oracle, and
14
+ * (b) the BY-CONSTRUCTION PARITY the caller refactor relies on: with no
15
+ * direction/structured, the wrapper === the old inline `buildImagePrompt`
16
+ * call + empty-check, byte-for-byte.
17
+ */
18
+
19
+ // flux-2-max supports reference images (used to assert refs survive the gate).
20
+ const REF_PROVIDER = "flux-2-max"
21
+
22
+ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
23
+ it("returns the bare userPrompt verbatim with no direction/structured", () => {
24
+ const result = assembleImageInput({
25
+ userPrompt: "a knight",
26
+ provider: REF_PROVIDER,
27
+ })
28
+ expect(result).toEqual({
29
+ nativeNegativePrompt: undefined,
30
+ prompt: "a knight",
31
+ referenceImageUrls: undefined,
32
+ })
33
+ })
34
+
35
+ it("bakes a framing hint and forwards a bound reference's URL", () => {
36
+ const ref: ConnectedReference = {
37
+ id: "ref-hero",
38
+ defaultName: "Image 1",
39
+ source: "manual",
40
+ url: "https://r2.example/hero.png",
41
+ }
42
+ const result = assembleImageInput({
43
+ userPrompt: "a knight",
44
+ provider: REF_PROVIDER,
45
+ connectedReferences: [ref],
46
+ direction: { framingId: "medium-shot" },
47
+ })
48
+ expect(result.prompt).toBe(`a knight. ${getFramingPromptHint("medium-shot")}`)
49
+ expect(result.referenceImageUrls).toEqual(["https://r2.example/hero.png"])
50
+ })
51
+
52
+ it("folds Shot Type + Angle independently (separate framing pills)", () => {
53
+ const result = assembleImageInput({
54
+ userPrompt: "a knight",
55
+ provider: REF_PROVIDER,
56
+ direction: { framingId: "medium-shot", framingAngleId: "low-angle" },
57
+ })
58
+ expect(result.prompt).toBe(
59
+ `a knight. ${getFramingPromptHint("medium-shot")}. ${getFramingPromptHint("low-angle")}`,
60
+ )
61
+ })
62
+
63
+ it("folds multi-dimension direction (Composer Framing path)", () => {
64
+ const kira: ConnectedReference = {
65
+ id: "kira-id",
66
+ defaultName: "Kira",
67
+ source: "wired-character",
68
+ url: "https://r2.example/kira.png",
69
+ description: "a young woman with red hair",
70
+ characterSlug: "kira",
71
+ variantSlug: undefined,
72
+ characterCanonicalDescription: "a young woman with red hair",
73
+ variantDescription: null,
74
+ variantDisplayName: "canonical",
75
+ }
76
+ const result = assembleImageInput({
77
+ userPrompt: "walking through the forest",
78
+ provider: REF_PROVIDER,
79
+ connectedReferences: [kira],
80
+ direction: { framingId: "wide-shot", lightingId: "golden-hour" },
81
+ })
82
+ expect(result.prompt).toContain("walking through the forest")
83
+ expect(result.prompt).toContain(getFramingPromptHint("wide-shot"))
84
+ expect(result.prompt).toContain(getLightingPromptHint("golden-hour"))
85
+ expect(result.referenceImageUrls).toEqual(["https://r2.example/kira.png"])
86
+ })
87
+
88
+ it("appends a rendered structured-fields fragment", () => {
89
+ const result = assembleImageInput({
90
+ userPrompt: "a portrait",
91
+ provider: REF_PROVIDER,
92
+ structured: { person: { age: 30, gender: "woman", expression: "calm" } },
93
+ })
94
+ expect(result.prompt).toBe("a portrait. Subject: 30 years old, woman, calm expression.")
95
+ })
96
+ })
97
+
98
+ describe("assembleImageInput — empty-prompt throw (opt-in)", () => {
99
+ it("throws on a truly-empty FINAL prompt when throwOnEmpty=true", () => {
100
+ expect(() =>
101
+ assembleImageInput({ userPrompt: " ", provider: REF_PROVIDER, throwOnEmpty: true }),
102
+ ).toThrow(/No prompt/)
103
+ })
104
+
105
+ it("does NOT throw on a blank prompt when throwOnEmpty is omitted, and preserves it VERBATIM (backend parity)", () => {
106
+ const result = assembleImageInput({ userPrompt: " ", provider: REF_PROVIDER })
107
+ // No hints → exact no-op: the blank prompt is preserved byte-for-byte (the
108
+ // old platform path passed it straight to buildImagePrompt, which doesn't
109
+ // trim). Emptiness is decided by the throwOnEmpty check's own `.trim()`, not
110
+ // by mutating the prompt here.
111
+ expect(result.prompt).toBe(" ")
112
+ })
113
+
114
+ it("does NOT throw when a bound entity fills an otherwise-empty prompt", () => {
115
+ const kira: ConnectedReference = {
116
+ id: "kira-id",
117
+ defaultName: "Kira",
118
+ source: "wired-character",
119
+ url: "https://r2.example/kira.png",
120
+ characterSlug: "kira",
121
+ variantSlug: undefined,
122
+ characterCanonicalDescription: "a young woman with red hair",
123
+ variantDescription: null,
124
+ variantDisplayName: "canonical",
125
+ }
126
+ const result = assembleImageInput({
127
+ userPrompt: "",
128
+ provider: REF_PROVIDER,
129
+ connectedReferences: [kira],
130
+ throwOnEmpty: true,
131
+ })
132
+ // The canonical-fallback block filled the prompt → no throw.
133
+ expect(result.prompt.length).toBeGreaterThan(0)
134
+ })
135
+ })
136
+
137
+ describe("assembleImageInput — extras threading + per-provider ref gate", () => {
138
+ it("threads extra URLs into referenceImageUrls for a supporting provider", () => {
139
+ const result = assembleImageInput({
140
+ userPrompt: "a city street",
141
+ provider: REF_PROVIDER,
142
+ extraReferenceImageUrls: ["https://r2.example/upload-a.png"],
143
+ })
144
+ expect(result.referenceImageUrls).toEqual(["https://r2.example/upload-a.png"])
145
+ })
146
+
147
+ it("drops refs for a provider with NO reference support (gate delegated to buildImagePrompt)", () => {
148
+ // `flux-schnell` is a text-to-image model with no reference support, so the
149
+ // builder returns `referenceImageUrls: undefined`.
150
+ const result = assembleImageInput({
151
+ userPrompt: "a city street",
152
+ provider: "flux-schnell",
153
+ extraReferenceImageUrls: ["https://r2.example/upload-a.png"],
154
+ })
155
+ expect(result.referenceImageUrls).toBeUndefined()
156
+ })
157
+ })
158
+
159
+ /**
160
+ * BY-CONSTRUCTION PARITY: this is the contract the two platform-caller
161
+ * refactors rely on. With NO direction/structured, `assembleImageInput`
162
+ * must return EXACTLY what the old inline `buildImagePrompt` call produced
163
+ * for the same inputs — byte-for-byte on all three result fields.
164
+ */
165
+ describe("assembleImageInput — platform-caller parity (no direction)", () => {
166
+ const kira: ConnectedReference = {
167
+ id: "ref-kira",
168
+ defaultName: "Kira",
169
+ source: "wired-character",
170
+ description: "young woman with warm smile",
171
+ url: "https://r2/kira-portrait.png",
172
+ characterSlug: "kira",
173
+ variantSlug: undefined,
174
+ characterCanonicalDescription: "young woman, brown eyes, auburn hair",
175
+ variantDescription: null,
176
+ variantDisplayName: "canonical",
177
+ }
178
+
179
+ const fixtures: Array<{ name: string; cfg: Parameters<typeof buildImagePrompt>[0] }> = [
180
+ {
181
+ name: "plain prompt, no refs",
182
+ cfg: { prompt: "a knight on a hill", provider: REF_PROVIDER },
183
+ },
184
+ {
185
+ name: "directRefs via referenceImageUrls + style + negativePrompt",
186
+ cfg: {
187
+ prompt: "a city at night",
188
+ provider: REF_PROVIDER,
189
+ style: "cinematic",
190
+ negativePrompt: "blurry, low quality",
191
+ referenceImageUrls: ["https://r2/a.png", "https://r2/b.png"],
192
+ },
193
+ },
194
+ {
195
+ name: "connectedReferences (wired character canonical fallback)",
196
+ cfg: {
197
+ prompt: "@kira:1 walking through the rain",
198
+ provider: REF_PROVIDER,
199
+ connectedReferences: [kira],
200
+ suppressedCanonicalCharacterIds: [],
201
+ identityMeta: [],
202
+ },
203
+ },
204
+ {
205
+ name: "empty prompt (backend never throws here)",
206
+ cfg: { prompt: "", provider: REF_PROVIDER, referenceImageUrls: [] },
207
+ },
208
+ {
209
+ // Regression guard: the old platform path passed the prompt straight to
210
+ // buildImagePrompt (no trim). The wrapper's no-direction path MUST return
211
+ // it verbatim — trimming here would change the assembled prompt + the
212
+ // recorded jobs.input_data byte-for-byte.
213
+ name: "trailing-whitespace prompt — NOT trimmed (exact no-op parity)",
214
+ cfg: { prompt: "a knight on a hill \n", provider: REF_PROVIDER },
215
+ },
216
+ {
217
+ name: "leading-whitespace prompt — NOT trimmed (exact no-op parity)",
218
+ cfg: { prompt: " a city at night", provider: REF_PROVIDER },
219
+ },
220
+ ]
221
+
222
+ for (const { name, cfg } of fixtures) {
223
+ it(`matches the old inline buildImagePrompt for: ${name}`, () => {
224
+ const oldResult = buildImagePrompt(cfg)
225
+
226
+ const wrapped = assembleImageInput({
227
+ userPrompt: cfg.prompt,
228
+ provider: cfg.provider,
229
+ connectedReferences: cfg.connectedReferences,
230
+ extraReferenceImageUrls: cfg.referenceImageUrls as string[] | undefined,
231
+ negativePrompt: cfg.negativePrompt,
232
+ style: cfg.style,
233
+ referenceOrder: cfg.referenceOrder,
234
+ identityMeta: cfg.identityMeta,
235
+ suppressedCanonicalCharacterIds: cfg.suppressedCanonicalCharacterIds,
236
+ suppressedCanonicalLocationIds: cfg.suppressedCanonicalLocationIds,
237
+ characterDefs: cfg.characterDefs,
238
+ userTemplates: cfg.userTemplates,
239
+ flowTemplates: cfg.flowTemplates,
240
+ ancestorRefs: cfg.ancestorRefs,
241
+ skipCharacterMentions: cfg.skipCharacterMentions,
242
+ // no `throwOnEmpty` → backend parity (no throw)
243
+ })
244
+
245
+ expect(wrapped).toEqual(oldResult)
246
+ })
247
+ }
248
+ })