@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,237 @@
1
+ /**
2
+ * Voice-character catalog: age + gender + language + accent + timbre. Feeds
3
+ * Voice Design's voiceDescription field via the Sound aggregator.
4
+ *
5
+ * `language` is multi-pick (up to 3) for codeswitching / multilingual
6
+ * voice work. Distinct from `accent` — accent is HOW it sounds, language
7
+ * is WHAT'S being spoken.
8
+ */
9
+
10
+ import { pickIds } from "@nodaro/shared"
11
+
12
+ export interface VoiceCharacterEntry {
13
+ readonly id: string
14
+ readonly label: string
15
+ readonly description: string
16
+ readonly promptHint: string
17
+ }
18
+
19
+ export const VOICE_AGES: ReadonlyArray<VoiceCharacterEntry> = [
20
+ { id: "infant", label: "Infant", description: "0-2 years", promptHint: "infant" },
21
+ { id: "child", label: "Child", description: "5-12 years", promptHint: "child" },
22
+ { id: "preteen", label: "Preteen", description: "10-13 years", promptHint: "preteen" },
23
+ { id: "teen", label: "Teen", description: "13-19 years", promptHint: "teen" },
24
+ { id: "young-adult", label: "Young Adult", description: "20-35 years", promptHint: "young adult" },
25
+ { id: "middle-aged", label: "Middle-aged", description: "36-55 years", promptHint: "middle-aged" },
26
+ { id: "mature", label: "Mature", description: "55-70 years", promptHint: "mature" },
27
+ { id: "elderly", label: "Elderly", description: "70+ years", promptHint: "elderly" },
28
+ ] as const
29
+
30
+ export const VOICE_GENDERS: ReadonlyArray<VoiceCharacterEntry> = [
31
+ { id: "male", label: "Male", description: "Male voice", promptHint: "male" },
32
+ { id: "female", label: "Female", description: "Female voice", promptHint: "female" },
33
+ { id: "androgynous", label: "Androgynous", description: "Gender-neutral", promptHint: "androgynous" },
34
+ ] as const
35
+
36
+ export const VOICE_LANGUAGES: ReadonlyArray<VoiceCharacterEntry> = [
37
+ { id: "english", label: "English", description: "English", promptHint: "English" },
38
+ { id: "spanish", label: "Spanish", description: "Spanish (Castilian / LatAm)", promptHint: "Spanish" },
39
+ { id: "french", label: "French", description: "French", promptHint: "French" },
40
+ { id: "german", label: "German", description: "German", promptHint: "German" },
41
+ { id: "italian", label: "Italian", description: "Italian", promptHint: "Italian" },
42
+ { id: "portuguese", label: "Portuguese", description: "Portuguese / Brazilian", promptHint: "Portuguese" },
43
+ { id: "dutch", label: "Dutch", description: "Dutch", promptHint: "Dutch" },
44
+ { id: "russian", label: "Russian", description: "Russian", promptHint: "Russian" },
45
+ { id: "polish", label: "Polish", description: "Polish", promptHint: "Polish" },
46
+ { id: "ukrainian", label: "Ukrainian", description: "Ukrainian", promptHint: "Ukrainian" },
47
+ { id: "swedish", label: "Swedish", description: "Swedish", promptHint: "Swedish" },
48
+ { id: "norwegian", label: "Norwegian", description: "Norwegian", promptHint: "Norwegian" },
49
+ { id: "danish", label: "Danish", description: "Danish", promptHint: "Danish" },
50
+ { id: "finnish", label: "Finnish", description: "Finnish", promptHint: "Finnish" },
51
+ { id: "greek", label: "Greek", description: "Greek", promptHint: "Greek" },
52
+ { id: "turkish", label: "Turkish", description: "Turkish", promptHint: "Turkish" },
53
+ { id: "arabic", label: "Arabic", description: "Modern Standard Arabic", promptHint: "Arabic" },
54
+ { id: "hebrew", label: "Hebrew", description: "Hebrew", promptHint: "Hebrew" },
55
+ { id: "persian", label: "Persian", description: "Persian / Farsi", promptHint: "Persian" },
56
+ { id: "hindi", label: "Hindi", description: "Hindi", promptHint: "Hindi" },
57
+ { id: "bengali", label: "Bengali", description: "Bengali / Bangla", promptHint: "Bengali" },
58
+ { id: "tamil", label: "Tamil", description: "Tamil", promptHint: "Tamil" },
59
+ { id: "urdu", label: "Urdu", description: "Urdu", promptHint: "Urdu" },
60
+ { id: "tagalog", label: "Tagalog", description: "Tagalog / Filipino", promptHint: "Tagalog" },
61
+ { id: "indonesian", label: "Indonesian", description: "Bahasa Indonesia", promptHint: "Indonesian" },
62
+ { id: "thai", label: "Thai", description: "Thai", promptHint: "Thai" },
63
+ { id: "vietnamese", label: "Vietnamese", description: "Vietnamese", promptHint: "Vietnamese" },
64
+ { id: "mandarin", label: "Mandarin", description: "Mandarin Chinese", promptHint: "Mandarin Chinese" },
65
+ { id: "cantonese", label: "Cantonese", description: "Cantonese Chinese", promptHint: "Cantonese" },
66
+ { id: "japanese", label: "Japanese", description: "Japanese", promptHint: "Japanese" },
67
+ { id: "korean", label: "Korean", description: "Korean", promptHint: "Korean" },
68
+ { id: "swahili", label: "Swahili", description: "Swahili", promptHint: "Swahili" },
69
+ { id: "yoruba", label: "Yoruba", description: "Yoruba", promptHint: "Yoruba" },
70
+ ] as const
71
+
72
+ export const VOICE_ACCENTS: ReadonlyArray<VoiceCharacterEntry> = [
73
+ // North America
74
+ { id: "general-american", label: "General American", description: "Neutral US accent", promptHint: "general American" },
75
+ { id: "southern-us", label: "Southern US", description: "US Southern drawl", promptHint: "Southern US" },
76
+ { id: "new-york", label: "New York", description: "NY metro accent", promptHint: "New York" },
77
+ { id: "boston", label: "Boston", description: "Boston / Mass accent", promptHint: "Boston" },
78
+ { id: "midwestern-us", label: "Midwestern", description: "US heartland", promptHint: "Midwestern American" },
79
+ { id: "chicago", label: "Chicago", description: "Upper-Midwest urban", promptHint: "Chicago" },
80
+ { id: "appalachian", label: "Appalachian", description: "Mountain South", promptHint: "Appalachian" },
81
+ { id: "canadian", label: "Canadian", description: "Canada English", promptHint: "Canadian" },
82
+ // British Isles
83
+ { id: "british-rp", label: "British RP", description: "Received Pronunciation", promptHint: "British RP" },
84
+ { id: "cockney", label: "Cockney", description: "London working-class", promptHint: "Cockney" },
85
+ { id: "estuary-english", label: "Estuary", description: "South-East England", promptHint: "Estuary English" },
86
+ { id: "northern-english", label: "Northern English", description: "Manchester / Yorkshire", promptHint: "Northern English" },
87
+ { id: "scouse", label: "Scouse", description: "Liverpool", promptHint: "Scouse" },
88
+ { id: "geordie", label: "Geordie", description: "Newcastle", promptHint: "Geordie" },
89
+ { id: "scottish", label: "Scottish", description: "Scotland", promptHint: "Scottish" },
90
+ { id: "irish", label: "Irish", description: "Ireland", promptHint: "Irish" },
91
+ { id: "welsh", label: "Welsh", description: "Wales", promptHint: "Welsh" },
92
+ // English-speaking world
93
+ { id: "australian", label: "Australian", description: "Australia", promptHint: "Australian" },
94
+ { id: "new-zealand", label: "New Zealand", description: "NZ Kiwi", promptHint: "New Zealand" },
95
+ { id: "south-african", label: "South African", description: "South Africa", promptHint: "South African" },
96
+ { id: "indian-english", label: "Indian English", description: "South Asian English", promptHint: "Indian English" },
97
+ { id: "caribbean", label: "Caribbean", description: "Caribbean English", promptHint: "Caribbean" },
98
+ { id: "jamaican", label: "Jamaican", description: "Jamaican Patois", promptHint: "Jamaican" },
99
+ // Continental European-accented English
100
+ { id: "french-accented", label: "French", description: "French-accented English", promptHint: "French-accented" },
101
+ { id: "italian-accented", label: "Italian", description: "Italian-accented English", promptHint: "Italian-accented" },
102
+ { id: "german-accented", label: "German", description: "German-accented English", promptHint: "German-accented" },
103
+ { id: "dutch-accented", label: "Dutch", description: "Dutch-accented English", promptHint: "Dutch-accented" },
104
+ { id: "russian-accented", label: "Russian", description: "Russian-accented English", promptHint: "Russian-accented" },
105
+ { id: "polish-accented", label: "Polish", description: "Polish-accented English", promptHint: "Polish-accented" },
106
+ { id: "spanish-accented", label: "Spanish", description: "Spanish-accented English", promptHint: "Spanish-accented" },
107
+ { id: "portuguese-accented", label: "Portuguese", description: "Portuguese / Brazilian", promptHint: "Portuguese-accented" },
108
+ { id: "scandinavian-accented", label: "Scandinavian", description: "Nordic-accented English", promptHint: "Scandinavian-accented" },
109
+ // Latin America
110
+ { id: "mexican-accented", label: "Mexican", description: "Mexican-accented English", promptHint: "Mexican-accented" },
111
+ { id: "argentinian-accented", label: "Argentinian", description: "Río de la Plata", promptHint: "Argentinian-accented" },
112
+ // Asia / Middle East
113
+ { id: "japanese-accented", label: "Japanese", description: "Japanese-accented English", promptHint: "Japanese-accented" },
114
+ { id: "korean-accented", label: "Korean", description: "Korean-accented English", promptHint: "Korean-accented" },
115
+ { id: "chinese-accented", label: "Chinese", description: "Mandarin-accented", promptHint: "Chinese-accented" },
116
+ { id: "filipino-accented", label: "Filipino", description: "Filipino-accented English", promptHint: "Filipino-accented" },
117
+ { id: "arabic-accented", label: "Arabic", description: "Arabic-accented English", promptHint: "Arabic-accented" },
118
+ { id: "hebrew-accented", label: "Hebrew", description: "Israeli-accented English", promptHint: "Hebrew-accented" },
119
+ { id: "turkish-accented", label: "Turkish", description: "Turkish-accented English", promptHint: "Turkish-accented" },
120
+ { id: "persian-accented", label: "Persian", description: "Persian-accented English", promptHint: "Persian-accented" },
121
+ // Africa
122
+ { id: "nigerian-accented", label: "Nigerian", description: "Nigerian English", promptHint: "Nigerian-accented" },
123
+ // General
124
+ { id: "neutral-international", label: "Neutral International", description: "Region-agnostic", promptHint: "neutral international" },
125
+ { id: "transatlantic", label: "Transatlantic", description: "Mid-Atlantic theatrical", promptHint: "transatlantic" },
126
+ ] as const
127
+
128
+ export const VOICE_TIMBRES: ReadonlyArray<VoiceCharacterEntry> = [
129
+ { id: "warm", label: "Warm", description: "Rich, inviting", promptHint: "warm" },
130
+ { id: "smooth", label: "Smooth", description: "Clean, even", promptHint: "smooth" },
131
+ { id: "silky", label: "Silky", description: "Soft, refined", promptHint: "silky" },
132
+ { id: "velvety", label: "Velvety", description: "Lush, plush", promptHint: "velvety" },
133
+ { id: "raspy", label: "Raspy", description: "Rough, textured", promptHint: "raspy" },
134
+ { id: "gravelly", label: "Gravelly", description: "Deeply textured", promptHint: "gravelly" },
135
+ { id: "rough", label: "Rough", description: "Coarse, weathered", promptHint: "rough" },
136
+ { id: "husky", label: "Husky", description: "Throaty, hoarse", promptHint: "husky" },
137
+ { id: "breathy", label: "Breathy", description: "Airy, intimate", promptHint: "breathy" },
138
+ { id: "whispered", label: "Whispered", description: "Hushed, intimate", promptHint: "whispered" },
139
+ { id: "nasal", label: "Nasal", description: "Resonates in nose", promptHint: "nasal" },
140
+ { id: "twangy", label: "Twangy", description: "Sharp, regional", promptHint: "twangy" },
141
+ { id: "deep", label: "Deep", description: "Low pitch range", promptHint: "deep" },
142
+ { id: "booming", label: "Booming", description: "Resonant, large", promptHint: "booming" },
143
+ { id: "high-pitched", label: "High-pitched", description: "Upper register", promptHint: "high-pitched" },
144
+ { id: "squeaky", label: "Squeaky", description: "Thin, piercing", promptHint: "squeaky" },
145
+ { id: "bright", label: "Bright", description: "Crisp, forward", promptHint: "bright" },
146
+ { id: "dark", label: "Dark", description: "Heavy, somber", promptHint: "dark" },
147
+ { id: "youthful", label: "Youthful", description: "Light, fresh", promptHint: "youthful" },
148
+ { id: "authoritative",label: "Authoritative",description: "Commanding", promptHint: "authoritative" },
149
+ { id: "sultry", label: "Sultry", description: "Sensual, smoky", promptHint: "sultry" },
150
+ { id: "polished", label: "Polished", description: "Practiced, broadcast-ready", promptHint: "polished" },
151
+ ] as const
152
+
153
+ const AGE_BY_ID = new Map(VOICE_AGES.map((x) => [x.id, x]))
154
+ const GENDER_BY_ID = new Map(VOICE_GENDERS.map((x) => [x.id, x]))
155
+ const LANGUAGE_BY_ID = new Map(VOICE_LANGUAGES.map((x) => [x.id, x]))
156
+ const ACCENT_BY_ID = new Map(VOICE_ACCENTS.map((x) => [x.id, x]))
157
+ const TIMBRE_BY_ID = new Map(VOICE_TIMBRES.map((x) => [x.id, x]))
158
+
159
+ export function getVoiceAge(id: string | undefined) { return id ? AGE_BY_ID.get(id) : undefined }
160
+ export function getVoiceGender(id: string | undefined) { return id ? GENDER_BY_ID.get(id) : undefined }
161
+ export function getVoiceLanguage(id: string | undefined) { return id ? LANGUAGE_BY_ID.get(id) : undefined }
162
+ export function getVoiceAccent(id: string | undefined) { return id ? ACCENT_BY_ID.get(id) : undefined }
163
+ export function getVoiceTimbre(id: string | undefined) { return id ? TIMBRE_BY_ID.get(id) : undefined }
164
+
165
+ /**
166
+ * Compose a natural-language voice character clause.
167
+ * Examples (depending on which sub-fields are set):
168
+ * { age, gender, timbre, accent } → "middle-aged male voice with warm timbre and British RP accent"
169
+ * { timbre } → "warm timbre"
170
+ * { accent } → "British RP accent"
171
+ * { age, gender } → "middle-aged male voice"
172
+ * { language: ["english","spanish"] } → "English / Spanish voice"
173
+ * { } → ""
174
+ *
175
+ * `language` is multi-pick — multiple languages emit "English / Spanish"
176
+ * for codeswitching / multilingual voices.
177
+ */
178
+ export function buildVoiceCharacterHints(data: {
179
+ readonly preText?: string
180
+ readonly postText?: string
181
+ readonly age?: string
182
+ readonly gender?: string
183
+ readonly language?: string | ReadonlyArray<string>
184
+ readonly accent?: string
185
+ readonly timbre?: string
186
+ }): string {
187
+ const fragments: string[] = []
188
+ const pre = typeof data.preText === "string" ? data.preText.trim() : ""
189
+ if (pre) fragments.push(pre)
190
+
191
+ const age = getVoiceAge(data.age)
192
+ const gender = getVoiceGender(data.gender)
193
+ const accent = getVoiceAccent(data.accent)
194
+ const timbre = getVoiceTimbre(data.timbre)
195
+
196
+ const langIds = pickIds(data.language)
197
+ const langHints = langIds
198
+ .map((id) => getVoiceLanguage(id)?.promptHint)
199
+ .filter((h): h is string => !!h)
200
+ const langClause = langHints.join(" / ")
201
+
202
+ const ageGender = [age?.promptHint, gender?.promptHint].filter(Boolean).join(" ")
203
+ const traits: string[] = []
204
+ if (timbre) traits.push(`${timbre.promptHint} timbre`)
205
+ if (accent) traits.push(`${accent.promptHint} accent`)
206
+
207
+ let core = ""
208
+ if (ageGender && traits.length > 0) {
209
+ core = `${ageGender} voice with ${traits.join(" and ")}`
210
+ } else if (ageGender) {
211
+ core = `${ageGender} voice`
212
+ } else if (traits.length > 0) {
213
+ core = traits.join(" and ")
214
+ }
215
+
216
+ let main = ""
217
+ if (langClause && core) main = `${langClause}-speaking ${core}`
218
+ else if (langClause) main = `${langClause} voice`
219
+ else main = core
220
+
221
+ if (main) fragments.push(main)
222
+
223
+ const post = typeof data.postText === "string" ? data.postText.trim() : ""
224
+ if (post) fragments.push(post)
225
+
226
+ return fragments.join(", ")
227
+ }
228
+
229
+ export const VOICE_CHARACTER_DEFAULT_DATA: {
230
+ preText?: string
231
+ postText?: string
232
+ age?: string
233
+ gender?: string
234
+ language?: string | ReadonlyArray<string>
235
+ accent?: string
236
+ timbre?: string
237
+ } = {}
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Voice-delivery catalog: pace + emotion + archetype. Feeds
3
+ * Voice Design's voiceDescription via the Sound aggregator.
4
+ */
5
+
6
+ export interface VoiceDeliveryEntry {
7
+ readonly id: string
8
+ readonly label: string
9
+ readonly description: string
10
+ readonly promptHint: string
11
+ }
12
+
13
+ export const VOICE_PACES: ReadonlyArray<VoiceDeliveryEntry> = [
14
+ { id: "very-slow", label: "Very Slow", description: "Deliberate, ponderous", promptHint: "very slow" },
15
+ { id: "slow", label: "Slow", description: "Measured", promptHint: "slow" },
16
+ { id: "drawl", label: "Drawl", description: "Drawn-out, lazy", promptHint: "drawled" },
17
+ { id: "measured", label: "Measured", description: "Steady, intentional", promptHint: "measured" },
18
+ { id: "moderate", label: "Moderate", description: "Conversational pace", promptHint: "moderate" },
19
+ { id: "punchy", label: "Punchy", description: "Clipped, emphatic", promptHint: "punchy" },
20
+ { id: "brisk", label: "Brisk", description: "Lively pace", promptHint: "brisk" },
21
+ { id: "rapid", label: "Rapid", description: "Fast", promptHint: "rapid" },
22
+ { id: "rapid-fire", label: "Rapid-fire", description: "Machine-gun delivery", promptHint: "rapid-fire" },
23
+ { id: "frenetic", label: "Frenetic", description: "Breathless, urgent", promptHint: "frenetic" },
24
+ { id: "halting", label: "Halting", description: "Pausing, uncertain", promptHint: "halting" },
25
+ ] as const
26
+
27
+ export const VOICE_EMOTIONS: ReadonlyArray<VoiceDeliveryEntry> = [
28
+ { id: "neutral", label: "Neutral", description: "Even, matter-of-fact", promptHint: "neutral" },
29
+ { id: "cheerful", label: "Cheerful", description: "Upbeat, positive", promptHint: "cheerful" },
30
+ { id: "warm", label: "Warm", description: "Friendly", promptHint: "warm" },
31
+ { id: "reassuring", label: "Reassuring", description: "Calming, grounding", promptHint: "reassuring" },
32
+ { id: "compassionate",label: "Compassionate",description: "Empathetic, caring", promptHint: "compassionate" },
33
+ { id: "somber", label: "Somber", description: "Serious, grave", promptHint: "somber" },
34
+ { id: "stoic", label: "Stoic", description: "Restrained, composed", promptHint: "stoic" },
35
+ { id: "urgent", label: "Urgent", description: "Pressing", promptHint: "urgent" },
36
+ { id: "frantic", label: "Frantic", description: "Panicked, scattered", promptHint: "frantic" },
37
+ { id: "anxious", label: "Anxious", description: "Tense", promptHint: "anxious" },
38
+ { id: "scared", label: "Scared", description: "Afraid, trembling", promptHint: "scared" },
39
+ { id: "excited", label: "Excited", description: "Energetic, animated", promptHint: "excited" },
40
+ { id: "enthusiastic",label: "Enthusiastic",description: "Hyped, eager", promptHint: "enthusiastic" },
41
+ { id: "wistful", label: "Wistful", description: "Yearning, nostalgic", promptHint: "wistful" },
42
+ { id: "melancholic", label: "Melancholic", description: "Sad, mourning", promptHint: "melancholic" },
43
+ { id: "menacing", label: "Menacing", description: "Threatening", promptHint: "menacing" },
44
+ { id: "angry", label: "Angry", description: "Hostile, hot", promptHint: "angry" },
45
+ { id: "sarcastic", label: "Sarcastic", description: "Wry, dry", promptHint: "sarcastic" },
46
+ { id: "smug", label: "Smug", description: "Self-satisfied", promptHint: "smug" },
47
+ { id: "playful", label: "Playful", description: "Light, teasing", promptHint: "playful" },
48
+ { id: "flirty", label: "Flirty", description: "Suggestive, teasing", promptHint: "flirty" },
49
+ { id: "intimate", label: "Intimate", description: "Close, personal", promptHint: "intimate" },
50
+ { id: "seductive", label: "Seductive", description: "Sensual, alluring", promptHint: "seductive" },
51
+ ] as const
52
+
53
+ export const VOICE_ARCHETYPES: ReadonlyArray<VoiceDeliveryEntry> = [
54
+ // Broadcast / Narration
55
+ { id: "newscaster", label: "Newscaster", description: "Authoritative news delivery", promptHint: "newscaster" },
56
+ { id: "anchor", label: "TV Anchor", description: "Polished broadcast", promptHint: "TV anchor" },
57
+ { id: "documentary-narrator", label: "Documentary Narrator", description: "Informative, measured", promptHint: "documentary narrator" },
58
+ { id: "nature-narrator", label: "Nature Narrator", description: "Soft-spoken Attenborough-style", promptHint: "nature documentary narrator" },
59
+ { id: "fairy-tale-narrator", label: "Fairy-Tale Narrator", description: "Storyteller for children", promptHint: "fairy-tale narrator" },
60
+ { id: "audiobook-narrator", label: "Audiobook Narrator", description: "Long-form fiction reading", promptHint: "audiobook narrator" },
61
+ { id: "podcaster", label: "Podcaster", description: "Conversational", promptHint: "podcaster" },
62
+ { id: "talk-show-host", label: "Talk Show Host", description: "Personable interviewer", promptHint: "talk show host" },
63
+ { id: "radio-dj", label: "Radio DJ", description: "Smooth FM voice", promptHint: "radio DJ" },
64
+ // Performance
65
+ { id: "asmr", label: "ASMR", description: "Whispered, intimate", promptHint: "ASMR" },
66
+ { id: "sportscaster", label: "Sportscaster", description: "Excitable play-by-play", promptHint: "sportscaster" },
67
+ { id: "auctioneer", label: "Auctioneer", description: "Rapid bidding chant", promptHint: "auctioneer" },
68
+ { id: "preacher", label: "Preacher", description: "Impassioned sermon", promptHint: "preacher" },
69
+ { id: "politician", label: "Politician", description: "Stump-speech rhetoric", promptHint: "politician" },
70
+ { id: "lawyer", label: "Lawyer", description: "Courtroom argument", promptHint: "lawyer" },
71
+ { id: "professor", label: "Professor", description: "Lecturer, academic", promptHint: "professor" },
72
+ { id: "infomercial", label: "Infomercial", description: "Selling, hyped", promptHint: "infomercial" },
73
+ { id: "voice-actor", label: "Voice Actor", description: "Animated character work", promptHint: "voice actor" },
74
+ { id: "drag-queen", label: "Drag Queen", description: "Theatrical, fierce", promptHint: "drag queen" },
75
+ // Character archetypes
76
+ { id: "villain", label: "Villain", description: "Sinister, scheming", promptHint: "villain" },
77
+ { id: "hero", label: "Hero", description: "Earnest, courageous", promptHint: "heroic" },
78
+ { id: "mentor", label: "Mentor", description: "Wise, guiding", promptHint: "mentor" },
79
+ { id: "comedian", label: "Comedian", description: "Comedic timing", promptHint: "comedic" },
80
+ { id: "stand-up-comic", label: "Stand-up Comic", description: "Punchline-driven", promptHint: "stand-up comic" },
81
+ { id: "drill-sergeant", label: "Drill Sergeant", description: "Barking commands", promptHint: "drill sergeant" },
82
+ { id: "meditation-guide", label: "Meditation Guide", description: "Soft, slow guidance", promptHint: "meditation guide" },
83
+ { id: "yoga-instructor", label: "Yoga Instructor", description: "Calm, grounding cues", promptHint: "yoga instructor" },
84
+ { id: "noir-detective", label: "Noir Detective", description: "Hard-boiled, brooding", promptHint: "noir detective" },
85
+ { id: "wizard", label: "Wizard", description: "Mystic, ancient", promptHint: "wizard" },
86
+ { id: "robot", label: "Robot", description: "Synthetic, mechanical", promptHint: "robotic" },
87
+ { id: "ai-assistant", label: "AI Assistant", description: "Polished synthetic helper", promptHint: "AI assistant" },
88
+ { id: "siri-style", label: "Virtual Assistant", description: "Siri/Alexa-style helper", promptHint: "virtual assistant" },
89
+ ] as const
90
+
91
+ const PACE_BY_ID = new Map(VOICE_PACES.map((x) => [x.id, x]))
92
+ const EMOTION_BY_ID = new Map(VOICE_EMOTIONS.map((x) => [x.id, x]))
93
+ const ARCHETYPE_BY_ID = new Map(VOICE_ARCHETYPES.map((x) => [x.id, x]))
94
+
95
+ export function getVoicePace(id: string | undefined) { return id ? PACE_BY_ID.get(id) : undefined }
96
+ export function getVoiceEmotion(id: string | undefined) { return id ? EMOTION_BY_ID.get(id) : undefined }
97
+ export function getVoiceArchetype(id: string | undefined) { return id ? ARCHETYPE_BY_ID.get(id) : undefined }
98
+
99
+ /**
100
+ * Compose a delivery clause.
101
+ * Examples:
102
+ * { pace, archetype, emotion } → "measured documentary-narrator-style delivery, reassuring tone"
103
+ * { archetype } → "documentary-narrator-style delivery"
104
+ * { emotion } → "reassuring tone"
105
+ * { pace } → "measured pace"
106
+ */
107
+ export function buildVoiceDeliveryHints(data: {
108
+ readonly preText?: string
109
+ readonly postText?: string
110
+ readonly pace?: string
111
+ readonly emotion?: string
112
+ readonly archetype?: string
113
+ }): string {
114
+ const fragments: string[] = []
115
+ const pre = typeof data.preText === "string" ? data.preText.trim() : ""
116
+ if (pre) fragments.push(pre)
117
+
118
+ const pace = getVoicePace(data.pace)
119
+ const emotion = getVoiceEmotion(data.emotion)
120
+ const archetype = getVoiceArchetype(data.archetype)
121
+
122
+ let deliveryClause = ""
123
+ if (pace && archetype) deliveryClause = `${pace.promptHint} ${archetype.promptHint}-style delivery`
124
+ else if (archetype) deliveryClause = `${archetype.promptHint}-style delivery`
125
+ else if (pace) deliveryClause = `${pace.promptHint} pace`
126
+
127
+ const emotionClause = emotion ? `${emotion.promptHint} tone` : ""
128
+
129
+ let main = ""
130
+ if (deliveryClause && emotionClause) main = `${deliveryClause}, ${emotionClause}`
131
+ else main = deliveryClause || emotionClause
132
+ if (main) fragments.push(main)
133
+
134
+ const post = typeof data.postText === "string" ? data.postText.trim() : ""
135
+ if (post) fragments.push(post)
136
+
137
+ return fragments.join(", ")
138
+ }
139
+
140
+ export const VOICE_DELIVERY_DEFAULT_DATA: {
141
+ preText?: string; postText?: string; pace?: string; emotion?: string; archetype?: string
142
+ } = {}
@@ -0,0 +1,179 @@
1
+ import { pickIds } from "@nodaro/shared"
2
+
3
+ export type WardrobeDimension =
4
+ | "archetype" | "top" | "bottom" | "outerwear" | "footwear"
5
+ | "headwear" | "accessories" | "color-palette" | "material" | "era"
6
+
7
+ export interface WardrobeEntry {
8
+ readonly id: string
9
+ readonly label: string
10
+ readonly dimension: WardrobeDimension
11
+ readonly promptHint: string
12
+ }
13
+
14
+ export interface WardrobeValue {
15
+ archetype?: string
16
+ top?: string
17
+ bottom?: string
18
+ outerwear?: string
19
+ footwear?: string
20
+ headwear?: string | ReadonlyArray<string>
21
+ accessories?: string | ReadonlyArray<string>
22
+ colorPalette?: string
23
+ material?: string
24
+ era?: string
25
+ }
26
+
27
+ export const WARDROBE_DIMENSION_ORDER: ReadonlyArray<WardrobeDimension> = [
28
+ "archetype", "top", "bottom", "outerwear", "footwear",
29
+ "headwear", "accessories", "color-palette", "material", "era",
30
+ ]
31
+
32
+ export const WARDROBE_CATEGORY_LABELS: Readonly<Record<WardrobeDimension, string>> = {
33
+ archetype: "Archetype", top: "Top", bottom: "Bottom", outerwear: "Outerwear",
34
+ footwear: "Footwear", headwear: "Headwear", accessories: "Accessories",
35
+ "color-palette": "Color Palette", material: "Material", era: "Era",
36
+ }
37
+
38
+ export const WARDROBE_FIELD_BY_DIMENSION: Record<WardrobeDimension, keyof WardrobeValue> = {
39
+ archetype: "archetype", top: "top", bottom: "bottom", outerwear: "outerwear",
40
+ footwear: "footwear", headwear: "headwear", accessories: "accessories",
41
+ "color-palette": "colorPalette", material: "material", era: "era",
42
+ }
43
+
44
+ /** Multi-pick dims emit each picked entry independently. */
45
+ const MULTI_PICK: ReadonlySet<WardrobeDimension> = new Set(["headwear", "accessories"])
46
+ /** Sentinel ids that mean "nothing here" and emit no hint. */
47
+ const NONE_IDS: ReadonlySet<string> = new Set(["wd-outer-none"])
48
+
49
+ export const WARDROBE: ReadonlyArray<WardrobeEntry> = [
50
+ // archetype
51
+ { id: "wd-casual", label: "Casual", dimension: "archetype", promptHint: "in casual everyday clothing" },
52
+ { id: "wd-business", label: "Business", dimension: "archetype", promptHint: "in smart business attire" },
53
+ { id: "wd-formal", label: "Formal", dimension: "archetype", promptHint: "in elegant formal wear" },
54
+ { id: "wd-streetwear", label: "Streetwear", dimension: "archetype", promptHint: "in contemporary streetwear" },
55
+ { id: "wd-athletic", label: "Athletic", dimension: "archetype", promptHint: "in athletic sportswear" },
56
+ { id: "wd-fantasy", label: "Fantasy", dimension: "archetype", promptHint: "in fantasy costume" },
57
+ { id: "wd-scifi", label: "Sci-Fi", dimension: "archetype", promptHint: "in futuristic sci-fi attire" },
58
+ { id: "wd-historical", label: "Historical", dimension: "archetype", promptHint: "in period historical dress" },
59
+ { id: "wd-uniform", label: "Uniform", dimension: "archetype", promptHint: "in a uniform" },
60
+ { id: "wd-loungewear", label: "Loungewear", dimension: "archetype", promptHint: "in relaxed loungewear" },
61
+ // top
62
+ { id: "wd-tshirt", label: "T-Shirt", dimension: "top", promptHint: "wearing a t-shirt" },
63
+ { id: "wd-blouse", label: "Blouse", dimension: "top", promptHint: "wearing a blouse" },
64
+ { id: "wd-hoodie", label: "Hoodie", dimension: "top", promptHint: "wearing a hoodie" },
65
+ { id: "wd-buttondown", label: "Button-down", dimension: "top", promptHint: "wearing a button-down shirt" },
66
+ { id: "wd-tank", label: "Tank", dimension: "top", promptHint: "wearing a tank top" },
67
+ { id: "wd-sweater", label: "Sweater", dimension: "top", promptHint: "wearing a sweater" },
68
+ { id: "wd-turtleneck", label: "Turtleneck", dimension: "top", promptHint: "wearing a turtleneck" },
69
+ // bottom
70
+ { id: "wd-jeans", label: "Jeans", dimension: "bottom", promptHint: "wearing jeans" },
71
+ { id: "wd-trousers", label: "Trousers", dimension: "bottom", promptHint: "wearing tailored trousers" },
72
+ { id: "wd-skirt", label: "Skirt", dimension: "bottom", promptHint: "wearing a skirt" },
73
+ { id: "wd-shorts", label: "Shorts", dimension: "bottom", promptHint: "wearing shorts" },
74
+ { id: "wd-leggings", label: "Leggings", dimension: "bottom", promptHint: "wearing leggings" },
75
+ { id: "wd-cargo", label: "Cargo", dimension: "bottom", promptHint: "wearing cargo pants" },
76
+ // outerwear
77
+ { id: "wd-outer-none", label: "None", dimension: "outerwear", promptHint: "" },
78
+ { id: "wd-leatherjkt", label: "Leather Jacket", dimension: "outerwear", promptHint: "in a leather jacket" },
79
+ { id: "wd-blazer", label: "Blazer", dimension: "outerwear", promptHint: "in a blazer" },
80
+ { id: "wd-trench", label: "Trench Coat", dimension: "outerwear", promptHint: "in a trench coat" },
81
+ { id: "wd-parka", label: "Parka", dimension: "outerwear", promptHint: "in a parka" },
82
+ { id: "wd-cloak", label: "Cloak", dimension: "outerwear", promptHint: "in a flowing cloak" },
83
+ { id: "wd-denimjkt", label: "Denim Jacket", dimension: "outerwear", promptHint: "in a denim jacket" },
84
+ { id: "wd-cardigan", label: "Cardigan", dimension: "outerwear", promptHint: "in a cardigan" },
85
+ // footwear
86
+ { id: "wd-sneakers", label: "Sneakers", dimension: "footwear", promptHint: "wearing sneakers" },
87
+ { id: "wd-boots", label: "Boots", dimension: "footwear", promptHint: "wearing boots" },
88
+ { id: "wd-heels", label: "Heels", dimension: "footwear", promptHint: "wearing heels" },
89
+ { id: "wd-sandals", label: "Sandals", dimension: "footwear", promptHint: "wearing sandals" },
90
+ { id: "wd-dressshoe", label: "Dress Shoes", dimension: "footwear", promptHint: "wearing dress shoes" },
91
+ { id: "wd-barefoot", label: "Barefoot", dimension: "footwear", promptHint: "barefoot" },
92
+ // headwear (multi)
93
+ { id: "wd-cap", label: "Cap", dimension: "headwear", promptHint: "wearing a cap" },
94
+ { id: "wd-beanie", label: "Beanie", dimension: "headwear", promptHint: "wearing a beanie" },
95
+ { id: "wd-widehat", label: "Wide-brim Hat", dimension: "headwear", promptHint: "wearing a wide-brim hat" },
96
+ { id: "wd-hood", label: "Hood", dimension: "headwear", promptHint: "with the hood up" },
97
+ { id: "wd-crown", label: "Crown", dimension: "headwear", promptHint: "wearing a crown" },
98
+ { id: "wd-helmet", label: "Helmet", dimension: "headwear", promptHint: "wearing a helmet" },
99
+ // accessories (multi)
100
+ { id: "wd-glasses", label: "Glasses", dimension: "accessories", promptHint: "wearing glasses" },
101
+ { id: "wd-sunglasses", label: "Sunglasses", dimension: "accessories", promptHint: "wearing sunglasses" },
102
+ { id: "wd-scarf", label: "Scarf", dimension: "accessories", promptHint: "wearing a scarf" },
103
+ { id: "wd-gloves", label: "Gloves", dimension: "accessories", promptHint: "wearing gloves" },
104
+ { id: "wd-jewelry", label: "Jewelry", dimension: "accessories", promptHint: "wearing statement jewelry" },
105
+ { id: "wd-belt", label: "Belt", dimension: "accessories", promptHint: "with a belt" },
106
+ { id: "wd-watch", label: "Watch", dimension: "accessories", promptHint: "wearing a watch" },
107
+ // color-palette
108
+ { id: "wd-neutral", label: "Neutral", dimension: "color-palette", promptHint: "in a neutral color palette" },
109
+ { id: "wd-all-black", label: "All Black", dimension: "color-palette", promptHint: "in an all-black palette" },
110
+ { id: "wd-earth", label: "Earth Tones", dimension: "color-palette", promptHint: "in earth-tone colors" },
111
+ { id: "wd-pastel", label: "Pastel", dimension: "color-palette", promptHint: "in pastel colors" },
112
+ { id: "wd-jewel", label: "Jewel Tones", dimension: "color-palette", promptHint: "in rich jewel-tone colors" },
113
+ { id: "wd-neon", label: "Neon", dimension: "color-palette", promptHint: "in vivid neon colors" },
114
+ { id: "wd-all-white", label: "All White", dimension: "color-palette", promptHint: "in an all-white palette" },
115
+ // material
116
+ { id: "wd-cotton", label: "Cotton", dimension: "material", promptHint: "in cotton fabric" },
117
+ { id: "wd-denim", label: "Denim", dimension: "material", promptHint: "in denim" },
118
+ { id: "wd-leather", label: "Leather", dimension: "material", promptHint: "in leather" },
119
+ { id: "wd-silk", label: "Silk", dimension: "material", promptHint: "in silk" },
120
+ { id: "wd-wool", label: "Wool", dimension: "material", promptHint: "in wool" },
121
+ { id: "wd-latex", label: "Latex", dimension: "material", promptHint: "in latex" },
122
+ { id: "wd-linen", label: "Linen", dimension: "material", promptHint: "in linen" },
123
+ // era
124
+ { id: "wd-contemporary", label: "Contemporary", dimension: "era", promptHint: "in contemporary fashion" },
125
+ { id: "wd-1920s", label: "1920s", dimension: "era", promptHint: "in 1920s fashion" },
126
+ { id: "wd-1950s", label: "1950s", dimension: "era", promptHint: "in 1950s fashion" },
127
+ { id: "wd-1980s", label: "1980s", dimension: "era", promptHint: "in 1980s fashion" },
128
+ { id: "wd-victorian", label: "Victorian", dimension: "era", promptHint: "in Victorian-era dress" },
129
+ { id: "wd-medieval", label: "Medieval", dimension: "era", promptHint: "in medieval dress" },
130
+ { id: "wd-futuristic", label: "Futuristic", dimension: "era", promptHint: "in futuristic clothing" },
131
+ ] as const
132
+
133
+ const BY_ID: Map<string, WardrobeEntry> = new Map(WARDROBE.map((e) => [e.id, e]))
134
+
135
+ /** Entries grouped by dimension — precomputed once so per-dimension lookups
136
+ * (the picker calls one per dimension per render) are O(1) with stable array
137
+ * refs, instead of a full WARDROBE scan each call. */
138
+ const BY_DIMENSION: Map<WardrobeDimension, WardrobeEntry[]> = (() => {
139
+ const map = new Map<WardrobeDimension, WardrobeEntry[]>()
140
+ for (const e of WARDROBE) {
141
+ const list = map.get(e.dimension)
142
+ if (list) list.push(e)
143
+ else map.set(e.dimension, [e])
144
+ }
145
+ return map
146
+ })()
147
+
148
+ export function getWardrobeEntry(id: string | undefined | null): WardrobeEntry | undefined {
149
+ return id ? BY_ID.get(id) : undefined
150
+ }
151
+
152
+ export function getWardrobePromptHint(id: string | undefined | null): string {
153
+ return getWardrobeEntry(id)?.promptHint ?? ""
154
+ }
155
+
156
+ export function getWardrobeEntriesByDimension(dim: WardrobeDimension): WardrobeEntry[] {
157
+ return BY_DIMENSION.get(dim) ?? []
158
+ }
159
+
160
+ export function buildWardrobeHints(value: Record<string, unknown> & WardrobeValue): string[] {
161
+ const hints: string[] = []
162
+ for (const dimension of WARDROBE_DIMENSION_ORDER) {
163
+ const field = WARDROBE_FIELD_BY_DIMENSION[dimension]
164
+ const raw = value[field]
165
+ if (MULTI_PICK.has(dimension)) {
166
+ for (const id of pickIds(raw)) {
167
+ if (NONE_IDS.has(id)) continue
168
+ const h = getWardrobePromptHint(id)
169
+ if (h) hints.push(h)
170
+ }
171
+ continue
172
+ }
173
+ const id = typeof raw === "string" ? raw : undefined
174
+ if (!id || NONE_IDS.has(id)) continue
175
+ const h = getWardrobePromptHint(id)
176
+ if (h) hints.push(h)
177
+ }
178
+ return hints
179
+ }