@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,858 @@
1
+ /**
2
+ * Public, discoverable registry of the parameter-picker catalogs.
3
+ *
4
+ * This is the pure-data mirror of the frontend picker registry
5
+ * (`frontend/src/lib/parameter-picker-registry.tsx`). It contains NO React —
6
+ * just the catalog metadata + flattened options — so it can be consumed by
7
+ * the backend, the public SDK, docs tooling, and the editor alike from one
8
+ * source of truth.
9
+ *
10
+ * Two kinds of entry (mirroring the frontend):
11
+ * - "single": one value field whose value is a string id chosen from a
12
+ * catalog. `options` carries the full flattened catalog (id/label/
13
+ * description/category/promptHint), so a consumer can render a picker or
14
+ * resolve an id → prompt fragment without importing the heavy frontend
15
+ * registry.
16
+ * - "multi": several value fields (e.g. Framing.shotSize + .angle + …).
17
+ * Structured / multi-dimensional. There is no single catalog to flatten,
18
+ * so instead of top-level `options` each multi entry carries a
19
+ * `dimensions` array — one self-describing entry per field
20
+ * ({ field, label, options }) in `fields` order — so a consumer can render
21
+ * a per-field picker or resolve any field's id → prompt fragment without
22
+ * importing the heavy frontend registry.
23
+ *
24
+ * A drift-guard test (`frontend/src/lib/__tests__/picker-catalogs-sync.test.ts`)
25
+ * asserts parity between this registry and the frontend one so the two cannot
26
+ * silently diverge.
27
+ *
28
+ * NOTE on `promptHint` for the four Object-entity catalogs (animal / vehicle /
29
+ * weapon / furniture): those catalog entries do NOT carry a `promptHint`
30
+ * field — at runtime `getParameterPromptHint` synthesizes the fragment from
31
+ * `label` + `description` ("featuring a golden retriever, …"). We reproduce the
32
+ * exact same phrasing here so each option's `promptHint` is non-empty AND
33
+ * matches what actually gets injected downstream.
34
+ */
35
+
36
+ import { SETTINGS, SETTING_CATEGORY_LABELS } from "./setting.js"
37
+ import { ATMOSPHERES } from "./atmosphere.js"
38
+ import { STYLES } from "./style.js"
39
+ import { COLOR_LOOKS, COLOR_LOOK_CATEGORY_LABELS, COLOR_LOOK_CATEGORY_ORDER } from "./color-look.js"
40
+ import { MOODS, MOOD_CATEGORY_LABELS, MOOD_CATEGORY_ORDER } from "./mood.js"
41
+ import { PHOTOGRAPHERS, PHOTOGRAPHER_CATEGORY_LABELS, PHOTOGRAPHER_CATEGORY_ORDER } from "./photographer.js"
42
+ import { AESTHETICS, AESTHETIC_CATEGORY_LABELS, AESTHETIC_CATEGORY_ORDER } from "./aesthetic.js"
43
+ import { ERAS, ERA_CATEGORY_LABELS, ERA_CATEGORY_ORDER } from "./era.js"
44
+ import { PHOTO_GENRES, PHOTO_GENRE_CATEGORY_LABELS, PHOTO_GENRE_CATEGORY_ORDER } from "./photo-genre.js"
45
+ import { BACKDROPS, BACKDROP_CATEGORY_LABELS, BACKDROP_CATEGORY_ORDER } from "./backdrop.js"
46
+ import { RENDER_QUALITIES } from "./render-quality.js"
47
+ import { COMPOSITION_EFFECTS } from "./composition-effects.js"
48
+ import { ACTION_FX, ACTION_FX_CATEGORY_LABELS, ACTION_FX_CATEGORY_ORDER } from "./action-fx.js"
49
+ import { LOOP_SUBJECTS, LOOP_SUBJECT_CATEGORY_LABELS, LOOP_SUBJECT_CATEGORY_ORDER } from "./loop-subject.js"
50
+ import { POST_PROCESS_EFFECTS } from "./post-process-effects.js"
51
+ import { CAMERA_MOTIONS, CAMERA_MOTION_CATEGORY_LABELS, CAMERA_MOTION_CATEGORY_ORDER } from "./camera-motions.js"
52
+ import { LENSES } from "./lens.js"
53
+ import { CAMERA_FORMATS } from "./camera-format.js"
54
+ import { TRANSITIONS, TRANSITION_CATEGORY_LABELS, TRANSITION_CATEGORY_ORDER } from "./transitions.js"
55
+ import { CHARACTER_FX, CHARACTER_FX_CATEGORY_LABELS, CHARACTER_FX_CATEGORY_ORDER } from "./character-fx.js"
56
+ import { POSES, POSE_CATEGORY_LABELS, POSE_CATEGORY_ORDER } from "./pose.js"
57
+ import { MATERIALS, MATERIAL_CATEGORY_LABELS, MATERIAL_CATEGORY_ORDER } from "./materials.js"
58
+ import { ANIMALS, ANIMAL_SUBCATEGORY_LABELS, ANIMAL_SUBCATEGORY_ORDER } from "@nodaro/shared"
59
+ import { VEHICLES, VEHICLE_SUBCATEGORY_LABELS, VEHICLE_SUBCATEGORY_ORDER } from "@nodaro/shared"
60
+ import { WEAPONS, WEAPON_SUBCATEGORY_LABELS, WEAPON_SUBCATEGORY_ORDER } from "@nodaro/shared"
61
+ import { FURNITURE, FURNITURE_SUBCATEGORY_LABELS, FURNITURE_SUBCATEGORY_ORDER } from "@nodaro/shared"
62
+ import { HELD_PROPS, HELD_PROP_CATEGORY_LABELS, HELD_PROP_CATEGORY_ORDER } from "./held-prop.js"
63
+ import { FRAMINGS, FRAMING_FIELD_BY_CATEGORY, FRAMING_CATEGORY_LABELS } from "./framing.js"
64
+ import { LIGHTINGS, LIGHTING_FIELD_BY_CATEGORY, LIGHTING_CATEGORY_ORDER, LIGHTING_CATEGORY_LABELS } from "./lighting.js"
65
+ import { TEMPORALS, TEMPORAL_FIELD_BY_CATEGORY, TEMPORAL_CATEGORY_LABELS } from "./temporal.js"
66
+ import { EXPOSURE_SETTINGS, EXPOSURE_FIELD_BY_CATEGORY, EXPOSURE_CATEGORY_LABELS } from "./exposure-settings.js"
67
+ import { PEOPLE, PERSON_FIELD_BY_DIMENSION, PERSON_DIMENSION_ORDER, PERSON_DIMENSION_LABELS } from "./person.js"
68
+ import { STYLINGS, STYLING_FIELD_BY_DIMENSION, STYLING_DIMENSION_ORDER, STYLING_DIMENSION_LABELS } from "./styling.js"
69
+ import { MUSIC_GENRES, MUSIC_ERAS } from "./music-genre.js"
70
+ import { MUSIC_ENERGIES, MUSIC_EMOTIONS, MUSIC_VIBES } from "./music-mood.js"
71
+ import { INSTRUMENTS, PRODUCTION_STYLES, VOCAL_PRESENCE, SINGING_STYLES } from "./instrumentation.js"
72
+ import { VOICE_AGES, VOICE_GENDERS, VOICE_LANGUAGES, VOICE_ACCENTS, VOICE_TIMBRES } from "./voice-character.js"
73
+ import { VOICE_PACES, VOICE_EMOTIONS, VOICE_ARCHETYPES } from "./voice-delivery.js"
74
+
75
+ export interface PickerOption {
76
+ readonly id: string
77
+ readonly label: string
78
+ readonly description?: string
79
+ /** The group id (matches `categoryOrder` / `categoryLabels`). */
80
+ readonly category?: string
81
+ readonly promptHint: string
82
+ /** Only present if the source catalog entry already carries a data icon/emoji/thumbnail field. */
83
+ readonly icon?: string
84
+ }
85
+
86
+ /**
87
+ * A self-describing dimension of a multi-dim picker: one value field plus the
88
+ * full option list valid for that field. `dimensions[i].field` mirrors the
89
+ * frontend picker's `fields[i]` exactly (same order), so a consumer can render
90
+ * a per-field picker or resolve an id → prompt fragment for any dimension
91
+ * without importing the heavy frontend registry.
92
+ */
93
+ export interface PickerDimension {
94
+ /** The node-data field this dimension writes to (e.g. "shotSize"). */
95
+ readonly field: string
96
+ /** Human-readable label for the dimension (e.g. "Shot Size"). */
97
+ readonly label: string
98
+ /** Flattened options valid for this field. */
99
+ readonly options: readonly PickerOption[]
100
+ }
101
+
102
+ export interface PickerCatalog {
103
+ readonly nodeType: string
104
+ readonly label: string
105
+ /** The i18n catalog id (mirrors the frontend entry's `catalogId`). */
106
+ readonly catalogId: string
107
+ readonly kind: "single" | "multi"
108
+ /** single only — the node-data field the chosen id is written to. */
109
+ readonly valueField?: string
110
+ /** single only — the catalog id selected by default. */
111
+ readonly defaultValue?: string
112
+ readonly categoryOrder?: readonly string[]
113
+ readonly categoryLabels?: Readonly<Record<string, string>>
114
+ /** single-dim: flattened catalog options. */
115
+ readonly options?: readonly PickerOption[]
116
+ /** multi-dim: the dimension keys (no single catalog to flatten). */
117
+ readonly fields?: readonly string[]
118
+ /** multi-dim: one self-describing entry per dimension field, in `fields` order. */
119
+ readonly dimensions?: readonly PickerDimension[]
120
+ }
121
+
122
+ /** Minimal shape every single-dim catalog entry satisfies. */
123
+ interface BaseCatalogEntry {
124
+ readonly id: string
125
+ readonly label: string
126
+ readonly description: string
127
+ readonly promptHint: string
128
+ }
129
+
130
+ /**
131
+ * Flatten a single-dim catalog array into `PickerOption`s.
132
+ *
133
+ * `categoryField` mirrors the field the frontend's `mapCat(ARR, "<field>")`
134
+ * used — `"category"`, `"subcategory"`, or omitted (no grouping). None of the
135
+ * catalogs carry an entry-level icon/emoji/thumbnail field, so `icon` is never
136
+ * emitted (icons are derived from the id via render helpers, which are React
137
+ * and intentionally excluded).
138
+ */
139
+ function toOptions<T extends BaseCatalogEntry>(
140
+ arr: ReadonlyArray<T>,
141
+ categoryField?: keyof T,
142
+ ): ReadonlyArray<PickerOption> {
143
+ return arr.map((e) => {
144
+ const opt: { -readonly [K in keyof PickerOption]?: PickerOption[K] } = {
145
+ id: e.id,
146
+ label: e.label,
147
+ promptHint: e.promptHint,
148
+ }
149
+ if (e.description) opt.description = e.description
150
+ if (categoryField) opt.category = e[categoryField] as unknown as string
151
+ return opt as PickerOption
152
+ })
153
+ }
154
+
155
+ /**
156
+ * The four Object-entity catalogs (animal / vehicle / weapon / furniture) lack
157
+ * a `promptHint` field; their fragment is synthesized at runtime from
158
+ * label + description. Reproduce the exact phrasing from
159
+ * `getParameterPromptHint` so the registry stays faithful + every option's
160
+ * `promptHint` is non-empty.
161
+ */
162
+ interface ObjectCatalogEntry {
163
+ readonly id: string
164
+ readonly label: string
165
+ readonly subcategory: string
166
+ readonly description: string
167
+ }
168
+ function objectOptions(
169
+ arr: ReadonlyArray<ObjectCatalogEntry>,
170
+ phrase: (label: string, description: string) => string,
171
+ ): ReadonlyArray<PickerOption> {
172
+ return arr.map((e) => ({
173
+ id: e.id,
174
+ label: e.label,
175
+ description: e.description,
176
+ category: e.subcategory,
177
+ promptHint: phrase(e.label.toLowerCase(), e.description),
178
+ }))
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Multi-dim `dimensions` builders
183
+ //
184
+ // Each builder is driven by the picker's `fields` array (the canonical order
185
+ // mirrored from `MULTI_PICKERS` in the frontend registry), so a dimension is
186
+ // emitted for exactly those fields, in that order. Catalog dimensions that
187
+ // have no corresponding picker field (e.g. lighting's lighting-ratio /
188
+ // color-temperature, person's regional-aesthetic, styling's outfit/top/…) are
189
+ // intentionally omitted — they aren't exposed by the multi-dim picker UI.
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /** "shotSize" → "Shot Size", "vocalPresence" → "Vocal Presence". */
193
+ function humanize(field: string): string {
194
+ return field
195
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
196
+ .replace(/^./, (c) => c.toUpperCase())
197
+ }
198
+
199
+ /**
200
+ * Pattern A — category-discriminated. ONE catalog whose entries each carry a
201
+ * `category`; a `fieldByCategory` map translates category → consumer field.
202
+ * For each picker `field`, options = catalog entries whose mapped field equals
203
+ * it; the dimension label comes from `categoryLabels[category]`.
204
+ */
205
+ function categoryDiscriminatedDims<C extends string, T extends BaseCatalogEntry & { category: C }>(
206
+ fields: ReadonlyArray<string>,
207
+ arr: ReadonlyArray<T>,
208
+ fieldByCategory: Readonly<Record<C, string>>,
209
+ categoryLabels: Readonly<Record<C, string>>,
210
+ ): ReadonlyArray<PickerDimension> {
211
+ // Resolve, per field, the category whose mapped field equals it.
212
+ const categoryByField = new Map<string, C>()
213
+ for (const cat of Object.keys(fieldByCategory) as C[]) {
214
+ categoryByField.set(fieldByCategory[cat], cat)
215
+ }
216
+ return fields.map((field) => {
217
+ const category = categoryByField.get(field)
218
+ if (!category) {
219
+ throw new Error(`picker-catalogs: no category maps to field "${field}"`)
220
+ }
221
+ const options = toOptions(arr.filter((e) => e.category === category))
222
+ return { field, label: categoryLabels[category], options }
223
+ })
224
+ }
225
+
226
+ /**
227
+ * Pattern A' — dimension-discriminated. ONE catalog whose entries each carry a
228
+ * `dimension` (kebab-case); a `fieldByDimension` map translates dimension →
229
+ * consumer field (camelCase). For each picker `field`, options = catalog
230
+ * entries whose mapped field equals it; the dimension label comes from
231
+ * `dimensionLabels[dimension]`.
232
+ */
233
+ function dimensionDiscriminatedDims<D extends string, T extends BaseCatalogEntry & { dimension: D }>(
234
+ fields: ReadonlyArray<string>,
235
+ arr: ReadonlyArray<T>,
236
+ fieldByDimension: Readonly<Record<D, string>>,
237
+ dimensionLabels: Readonly<Record<D, string>>,
238
+ ): ReadonlyArray<PickerDimension> {
239
+ const dimensionByField = new Map<string, D>()
240
+ for (const dim of Object.keys(fieldByDimension) as D[]) {
241
+ dimensionByField.set(fieldByDimension[dim], dim)
242
+ }
243
+ return fields.map((field) => {
244
+ const dimension = dimensionByField.get(field)
245
+ if (!dimension) {
246
+ throw new Error(`picker-catalogs: no dimension maps to field "${field}"`)
247
+ }
248
+ const options = toOptions(arr.filter((e) => e.dimension === dimension))
249
+ return { field, label: dimensionLabels[dimension], options }
250
+ })
251
+ }
252
+
253
+ /**
254
+ * Pattern B — one independent catalog array per field, in `fields` order.
255
+ * The dimension label is humanized from the field name.
256
+ */
257
+ function perFieldDims(
258
+ fieldArrays: ReadonlyArray<readonly [field: string, arr: ReadonlyArray<BaseCatalogEntry>]>,
259
+ ): ReadonlyArray<PickerDimension> {
260
+ return fieldArrays.map(([field, arr]) => ({
261
+ field,
262
+ label: humanize(field),
263
+ options: toOptions(arr),
264
+ }))
265
+ }
266
+
267
+ /**
268
+ * Pattern C — hierarchical music-genre. Three fields:
269
+ * - genre → MUSIC_GENRES (top-level)
270
+ * - subgenre → every MUSIC_GENRES[].subgenres flattened
271
+ * - era → MUSIC_ERAS
272
+ *
273
+ * Genre / subgenre / era entries each carry their own `promptHint` (the exact
274
+ * fragment `buildMusicGenreHints` injects for the genre and era cases). We
275
+ * pass those through directly rather than re-deriving via
276
+ * `getParameterPromptHint`, because that helper resolves a subgenre only in
277
+ * the context of its parent genre — a lone `{ subgenre }` yields an empty
278
+ * string. Pass-through keeps every option's hint non-empty and faithful.
279
+ */
280
+ function musicGenreDims(fields: ReadonlyArray<string>): ReadonlyArray<PickerDimension> {
281
+ const optionsByField: Record<string, ReadonlyArray<PickerOption>> = {
282
+ genre: MUSIC_GENRES.map((g) => ({ id: g.id, label: g.label, description: g.description, category: g.category, promptHint: g.promptHint })),
283
+ subgenre: MUSIC_GENRES.flatMap((g) =>
284
+ g.subgenres.map((s) => ({ id: s.id, label: s.label, promptHint: s.promptHint })),
285
+ ),
286
+ era: MUSIC_ERAS.map((e) => ({ id: e.id, label: e.label, description: e.description, promptHint: e.promptHint })),
287
+ }
288
+ return fields.map((field) => {
289
+ const options = optionsByField[field]
290
+ if (!options) throw new Error(`picker-catalogs: music-genre has no options for field "${field}"`)
291
+ return { field, label: humanize(field), options }
292
+ })
293
+ }
294
+
295
+ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
296
+ // -------- "Look" family --------
297
+ {
298
+ nodeType: "setting",
299
+ label: "Setting",
300
+ catalogId: "setting",
301
+ kind: "single",
302
+ valueField: "setting",
303
+ defaultValue: "forest",
304
+ // Frontend hardcodes this order (there is no SETTING_CATEGORY_ORDER export).
305
+ categoryOrder: ["indoor", "urban", "nature", "fantastical"],
306
+ categoryLabels: SETTING_CATEGORY_LABELS,
307
+ options: toOptions(SETTINGS, "category"),
308
+ },
309
+ {
310
+ nodeType: "atmosphere",
311
+ label: "Atmosphere",
312
+ catalogId: "atmosphere",
313
+ kind: "single",
314
+ valueField: "atmosphere",
315
+ defaultValue: "clear",
316
+ options: toOptions(ATMOSPHERES),
317
+ },
318
+ {
319
+ nodeType: "style",
320
+ label: "Style",
321
+ catalogId: "style",
322
+ kind: "single",
323
+ valueField: "style",
324
+ defaultValue: "cinematic",
325
+ options: toOptions(STYLES),
326
+ },
327
+ {
328
+ nodeType: "color-look",
329
+ label: "Color / Look",
330
+ catalogId: "color-look",
331
+ kind: "single",
332
+ valueField: "colorLook",
333
+ defaultValue: "warm",
334
+ categoryOrder: COLOR_LOOK_CATEGORY_ORDER,
335
+ categoryLabels: COLOR_LOOK_CATEGORY_LABELS,
336
+ options: toOptions(COLOR_LOOKS, "category"),
337
+ },
338
+ {
339
+ nodeType: "mood",
340
+ label: "Mood",
341
+ catalogId: "mood",
342
+ kind: "single",
343
+ valueField: "mood",
344
+ defaultValue: "calm",
345
+ categoryOrder: MOOD_CATEGORY_ORDER,
346
+ categoryLabels: MOOD_CATEGORY_LABELS,
347
+ options: toOptions(MOODS, "category"),
348
+ },
349
+ {
350
+ nodeType: "photographer",
351
+ label: "Photographer / Artist",
352
+ catalogId: "photographer",
353
+ kind: "single",
354
+ valueField: "photographer",
355
+ defaultValue: "tim-walker",
356
+ categoryOrder: PHOTOGRAPHER_CATEGORY_ORDER,
357
+ categoryLabels: PHOTOGRAPHER_CATEGORY_LABELS,
358
+ options: toOptions(PHOTOGRAPHERS, "category"),
359
+ },
360
+ {
361
+ nodeType: "aesthetic",
362
+ label: "Aesthetic / Microtrend",
363
+ catalogId: "aesthetic",
364
+ kind: "single",
365
+ valueField: "aesthetic",
366
+ defaultValue: "y2k",
367
+ categoryOrder: AESTHETIC_CATEGORY_ORDER,
368
+ categoryLabels: AESTHETIC_CATEGORY_LABELS,
369
+ options: toOptions(AESTHETICS, "category"),
370
+ },
371
+ {
372
+ nodeType: "era",
373
+ label: "Era / Period",
374
+ catalogId: "era",
375
+ kind: "single",
376
+ valueField: "era",
377
+ defaultValue: "1990s-mall",
378
+ categoryOrder: ERA_CATEGORY_ORDER,
379
+ categoryLabels: ERA_CATEGORY_LABELS,
380
+ options: toOptions(ERAS, "category"),
381
+ },
382
+ {
383
+ nodeType: "photo-genre",
384
+ label: "Photo Genre",
385
+ catalogId: "photo-genre",
386
+ kind: "single",
387
+ valueField: "photoGenre",
388
+ defaultValue: "fashion-editorial",
389
+ categoryOrder: PHOTO_GENRE_CATEGORY_ORDER,
390
+ categoryLabels: PHOTO_GENRE_CATEGORY_LABELS,
391
+ options: toOptions(PHOTO_GENRES, "category"),
392
+ },
393
+ {
394
+ nodeType: "backdrop",
395
+ label: "Backdrop",
396
+ catalogId: "backdrop",
397
+ kind: "single",
398
+ valueField: "backdrop",
399
+ defaultValue: "white-seamless",
400
+ categoryOrder: BACKDROP_CATEGORY_ORDER,
401
+ categoryLabels: BACKDROP_CATEGORY_LABELS,
402
+ options: toOptions(BACKDROPS, "category"),
403
+ },
404
+ {
405
+ nodeType: "render-quality",
406
+ label: "Render Quality",
407
+ catalogId: "render-quality",
408
+ kind: "single",
409
+ valueField: "renderQuality",
410
+ defaultValue: "raytracing",
411
+ options: toOptions(RENDER_QUALITIES),
412
+ },
413
+ {
414
+ nodeType: "composition-effects",
415
+ label: "Composition Effect",
416
+ catalogId: "composition-effects",
417
+ kind: "single",
418
+ valueField: "compositionEffect",
419
+ defaultValue: "bursting-through-frame",
420
+ options: toOptions(COMPOSITION_EFFECTS),
421
+ },
422
+ {
423
+ nodeType: "action-fx",
424
+ label: "Action FX",
425
+ catalogId: "action-fx",
426
+ kind: "single",
427
+ valueField: "actionFx",
428
+ defaultValue: "earthquake-tremor",
429
+ categoryOrder: ACTION_FX_CATEGORY_ORDER,
430
+ categoryLabels: ACTION_FX_CATEGORY_LABELS,
431
+ options: toOptions(ACTION_FX, "category"),
432
+ },
433
+ {
434
+ nodeType: "loop-subject",
435
+ label: "Loop Subject",
436
+ catalogId: "loop-subject",
437
+ kind: "single",
438
+ valueField: "loopSubject",
439
+ defaultValue: "tunnel",
440
+ categoryOrder: LOOP_SUBJECT_CATEGORY_ORDER,
441
+ categoryLabels: LOOP_SUBJECT_CATEGORY_LABELS,
442
+ options: toOptions(LOOP_SUBJECTS, "category"),
443
+ },
444
+ {
445
+ nodeType: "post-process-effects",
446
+ label: "Post-Process Effect",
447
+ catalogId: "post-process-effects",
448
+ kind: "single",
449
+ valueField: "postProcess",
450
+ defaultValue: "vignette-soft",
451
+ options: toOptions(POST_PROCESS_EFFECTS),
452
+ },
453
+
454
+ // -------- "Camera" family --------
455
+ {
456
+ nodeType: "camera-motion",
457
+ label: "Camera Motion",
458
+ catalogId: "camera-motions",
459
+ kind: "single",
460
+ valueField: "cameraMotion",
461
+ defaultValue: "static",
462
+ categoryOrder: CAMERA_MOTION_CATEGORY_ORDER,
463
+ categoryLabels: CAMERA_MOTION_CATEGORY_LABELS,
464
+ options: toOptions(CAMERA_MOTIONS, "category"),
465
+ },
466
+ {
467
+ nodeType: "lens",
468
+ label: "Lens",
469
+ catalogId: "lens",
470
+ kind: "single",
471
+ valueField: "lens",
472
+ defaultValue: "normal-50mm",
473
+ options: toOptions(LENSES),
474
+ },
475
+ {
476
+ nodeType: "camera-format",
477
+ label: "Camera / Film",
478
+ catalogId: "camera-format",
479
+ kind: "single",
480
+ valueField: "cameraFormat",
481
+ defaultValue: "35mm-film",
482
+ options: toOptions(CAMERA_FORMATS),
483
+ },
484
+ {
485
+ nodeType: "transition",
486
+ label: "Transition",
487
+ catalogId: "transitions",
488
+ kind: "single",
489
+ valueField: "transition",
490
+ defaultValue: "auto",
491
+ categoryOrder: TRANSITION_CATEGORY_ORDER,
492
+ categoryLabels: TRANSITION_CATEGORY_LABELS,
493
+ options: toOptions(TRANSITIONS, "category"),
494
+ },
495
+ {
496
+ nodeType: "character-fx",
497
+ label: "Character FX",
498
+ catalogId: "character-fx",
499
+ kind: "single",
500
+ valueField: "characterFx",
501
+ defaultValue: "auto",
502
+ categoryOrder: CHARACTER_FX_CATEGORY_ORDER,
503
+ categoryLabels: CHARACTER_FX_CATEGORY_LABELS,
504
+ options: toOptions(CHARACTER_FX, "category"),
505
+ },
506
+
507
+ // -------- "Subject / Object" family --------
508
+ {
509
+ nodeType: "pose",
510
+ label: "Pose",
511
+ catalogId: "pose",
512
+ kind: "single",
513
+ valueField: "pose",
514
+ defaultValue: "standing-upright",
515
+ categoryOrder: POSE_CATEGORY_ORDER,
516
+ categoryLabels: POSE_CATEGORY_LABELS,
517
+ options: toOptions(POSES, "category"),
518
+ },
519
+ {
520
+ nodeType: "material",
521
+ label: "Material",
522
+ catalogId: "materials",
523
+ kind: "single",
524
+ valueField: "material",
525
+ defaultValue: "silk",
526
+ categoryOrder: MATERIAL_CATEGORY_ORDER,
527
+ categoryLabels: MATERIAL_CATEGORY_LABELS,
528
+ options: toOptions(MATERIALS, "category"),
529
+ },
530
+ {
531
+ nodeType: "animal",
532
+ label: "Animal",
533
+ catalogId: "animals",
534
+ kind: "single",
535
+ valueField: "animal",
536
+ defaultValue: "dog-golden-retriever",
537
+ categoryOrder: ANIMAL_SUBCATEGORY_ORDER,
538
+ categoryLabels: ANIMAL_SUBCATEGORY_LABELS,
539
+ options: objectOptions(ANIMALS, (label, description) => `featuring a ${label}, ${description}`),
540
+ },
541
+ {
542
+ nodeType: "vehicle",
543
+ label: "Vehicle",
544
+ catalogId: "vehicles",
545
+ kind: "single",
546
+ valueField: "vehicle",
547
+ defaultValue: "sedan",
548
+ categoryOrder: VEHICLE_SUBCATEGORY_ORDER,
549
+ categoryLabels: VEHICLE_SUBCATEGORY_LABELS,
550
+ options: objectOptions(VEHICLES, (label, description) => `featuring a ${label}, ${description}`),
551
+ },
552
+ {
553
+ nodeType: "weapon",
554
+ label: "Weapon",
555
+ catalogId: "weapons",
556
+ kind: "single",
557
+ valueField: "weapon",
558
+ defaultValue: "katana",
559
+ categoryOrder: WEAPON_SUBCATEGORY_ORDER,
560
+ categoryLabels: WEAPON_SUBCATEGORY_LABELS,
561
+ options: objectOptions(WEAPONS, (label, description) => `with a ${label}, ${description}`),
562
+ },
563
+ {
564
+ nodeType: "furniture",
565
+ label: "Furniture",
566
+ catalogId: "furniture",
567
+ kind: "single",
568
+ valueField: "furniture",
569
+ defaultValue: "sofa",
570
+ categoryOrder: FURNITURE_SUBCATEGORY_ORDER,
571
+ categoryLabels: FURNITURE_SUBCATEGORY_LABELS,
572
+ options: objectOptions(FURNITURE, (label, description) => `including a ${label}, ${description}`),
573
+ },
574
+ {
575
+ nodeType: "held-prop",
576
+ label: "Held Prop",
577
+ catalogId: "held-prop",
578
+ kind: "single",
579
+ valueField: "heldProp",
580
+ defaultValue: "smartphone",
581
+ categoryOrder: HELD_PROP_CATEGORY_ORDER,
582
+ categoryLabels: HELD_PROP_CATEGORY_LABELS,
583
+ options: toOptions(HELD_PROPS, "category"),
584
+ },
585
+ ]
586
+
587
+ // Canonical per-picker `fields` order (mirrors `MULTI_PICKERS` in
588
+ // `frontend/src/lib/parameter-picker-registry.tsx`). Declared once and reused
589
+ // for both `fields` and the `dimensions` builder so the two can never drift.
590
+ const FRAMING_FIELDS = ["shotSize", "angle", "coverage", "composition", "vantage"] as const
591
+ // LIGHTING / PERSON / STYLING fields are DERIVED from the canonical
592
+ // dimension/category order + field map — the SAME source the picker components
593
+ // and the describe-to-picker analyzer already use. Deriving (not hand-listing)
594
+ // means the app-input `fields`, the `dimensions` builder, and the mirrored
595
+ // frontend registry can't drift from the actual node-data shape: a new
596
+ // dimension in the catalog flows through automatically. (As hand-lists these
597
+ // HAD drifted — styling omitted outfit/top/bottom/outerwear/legwear/footwear/
598
+ // hairState/wardrobeState; lighting omitted lightingRatio/colorTemperature;
599
+ // person omitted regionalAesthetic — so app-input cards silently dropped them.)
600
+ const LIGHTING_FIELDS: ReadonlyArray<string> = LIGHTING_CATEGORY_ORDER.map((c) => LIGHTING_FIELD_BY_CATEGORY[c])
601
+ const PERSON_FIELDS: ReadonlyArray<string> = PERSON_DIMENSION_ORDER.map((d) => PERSON_FIELD_BY_DIMENSION[d])
602
+ const STYLING_FIELDS: ReadonlyArray<string> = STYLING_DIMENSION_ORDER.map((d) => STYLING_FIELD_BY_DIMENSION[d])
603
+ const TEMPORAL_FIELDS = ["temporalSpeed", "temporalFreeze", "temporalDirection", "temporalShutter"] as const
604
+ const EXPOSURE_FIELDS = ["aperture", "shutterSpeed", "isoValue"] as const
605
+ const MUSIC_GENRE_FIELDS = ["genre", "subgenre", "era"] as const
606
+ const MUSIC_MOOD_FIELDS = ["energy", "emotion", "vibe"] as const
607
+ const INSTRUMENTATION_FIELDS = ["instruments", "production", "vocalPresence", "singingStyle"] as const
608
+ const VOICE_CHARACTER_FIELDS = ["age", "gender", "language", "accent", "timbre"] as const
609
+ const VOICE_DELIVERY_FIELDS = ["pace", "emotion", "archetype"] as const
610
+
611
+ const MULTI_CATALOGS: readonly PickerCatalog[] = [
612
+ {
613
+ nodeType: "framing",
614
+ label: "Framing",
615
+ catalogId: "framing",
616
+ kind: "multi",
617
+ fields: FRAMING_FIELDS,
618
+ dimensions: categoryDiscriminatedDims(FRAMING_FIELDS, FRAMINGS, FRAMING_FIELD_BY_CATEGORY, FRAMING_CATEGORY_LABELS),
619
+ },
620
+ {
621
+ nodeType: "lighting",
622
+ label: "Lighting",
623
+ catalogId: "lighting",
624
+ kind: "multi",
625
+ fields: LIGHTING_FIELDS,
626
+ dimensions: categoryDiscriminatedDims(LIGHTING_FIELDS, LIGHTINGS, LIGHTING_FIELD_BY_CATEGORY, LIGHTING_CATEGORY_LABELS),
627
+ },
628
+ {
629
+ nodeType: "person",
630
+ label: "Person",
631
+ catalogId: "person",
632
+ kind: "multi",
633
+ fields: PERSON_FIELDS,
634
+ dimensions: dimensionDiscriminatedDims(PERSON_FIELDS, PEOPLE, PERSON_FIELD_BY_DIMENSION, PERSON_DIMENSION_LABELS),
635
+ },
636
+ {
637
+ nodeType: "styling",
638
+ label: "Styling",
639
+ catalogId: "styling",
640
+ kind: "multi",
641
+ fields: STYLING_FIELDS,
642
+ dimensions: dimensionDiscriminatedDims(STYLING_FIELDS, STYLINGS, STYLING_FIELD_BY_DIMENSION, STYLING_DIMENSION_LABELS),
643
+ },
644
+ {
645
+ nodeType: "temporal",
646
+ label: "Temporal",
647
+ catalogId: "temporal",
648
+ kind: "multi",
649
+ fields: TEMPORAL_FIELDS,
650
+ dimensions: categoryDiscriminatedDims(TEMPORAL_FIELDS, TEMPORALS, TEMPORAL_FIELD_BY_CATEGORY, TEMPORAL_CATEGORY_LABELS),
651
+ },
652
+ {
653
+ nodeType: "exposure-settings",
654
+ label: "Exposure Settings",
655
+ catalogId: "exposure-settings",
656
+ kind: "multi",
657
+ fields: EXPOSURE_FIELDS,
658
+ dimensions: categoryDiscriminatedDims(EXPOSURE_FIELDS, EXPOSURE_SETTINGS, EXPOSURE_FIELD_BY_CATEGORY, EXPOSURE_CATEGORY_LABELS),
659
+ },
660
+ // -------- "Sound" family --------
661
+ {
662
+ nodeType: "music-genre",
663
+ label: "Music Genre",
664
+ catalogId: "music-genre",
665
+ kind: "multi",
666
+ fields: MUSIC_GENRE_FIELDS,
667
+ dimensions: musicGenreDims(MUSIC_GENRE_FIELDS),
668
+ },
669
+ {
670
+ nodeType: "music-mood",
671
+ label: "Music Mood",
672
+ catalogId: "music-mood",
673
+ kind: "multi",
674
+ fields: MUSIC_MOOD_FIELDS,
675
+ dimensions: perFieldDims([
676
+ ["energy", MUSIC_ENERGIES],
677
+ ["emotion", MUSIC_EMOTIONS],
678
+ ["vibe", MUSIC_VIBES],
679
+ ]),
680
+ },
681
+ {
682
+ nodeType: "instrumentation",
683
+ label: "Instrumentation",
684
+ catalogId: "instrumentation",
685
+ kind: "multi",
686
+ fields: INSTRUMENTATION_FIELDS,
687
+ dimensions: perFieldDims([
688
+ ["instruments", INSTRUMENTS],
689
+ ["production", PRODUCTION_STYLES],
690
+ ["vocalPresence", VOCAL_PRESENCE],
691
+ ["singingStyle", SINGING_STYLES],
692
+ ]),
693
+ },
694
+ {
695
+ nodeType: "voice-character",
696
+ label: "Voice Character",
697
+ catalogId: "voice-character",
698
+ kind: "multi",
699
+ fields: VOICE_CHARACTER_FIELDS,
700
+ dimensions: perFieldDims([
701
+ ["age", VOICE_AGES],
702
+ ["gender", VOICE_GENDERS],
703
+ ["language", VOICE_LANGUAGES],
704
+ ["accent", VOICE_ACCENTS],
705
+ ["timbre", VOICE_TIMBRES],
706
+ ]),
707
+ },
708
+ {
709
+ nodeType: "voice-delivery",
710
+ label: "Voice Delivery",
711
+ catalogId: "voice-delivery",
712
+ kind: "multi",
713
+ fields: VOICE_DELIVERY_FIELDS,
714
+ dimensions: perFieldDims([
715
+ ["pace", VOICE_PACES],
716
+ ["emotion", VOICE_EMOTIONS],
717
+ ["archetype", VOICE_ARCHETYPES],
718
+ ]),
719
+ },
720
+ ]
721
+
722
+ export const PICKER_CATALOGS: readonly PickerCatalog[] = [
723
+ ...SINGLE_CATALOGS,
724
+ ...MULTI_CATALOGS,
725
+ ]
726
+
727
+ /** Resolve a catalog by `nodeType` first, then by `catalogId`. */
728
+ export function getPickerCatalog(nodeTypeOrCatalogId: string): PickerCatalog | undefined {
729
+ return (
730
+ PICKER_CATALOGS.find((c) => c.nodeType === nodeTypeOrCatalogId) ??
731
+ PICKER_CATALOGS.find((c) => c.catalogId === nodeTypeOrCatalogId)
732
+ )
733
+ }
734
+
735
+ export function listPickerCatalogs(): readonly PickerCatalog[] {
736
+ return PICKER_CATALOGS
737
+ }
738
+
739
+ // ─── Summary + projection (for MCP / REST exposure) ──────────────────────────
740
+
741
+ export interface PickerCatalogSummary {
742
+ readonly nodeType: string
743
+ readonly label: string
744
+ readonly catalogId: string
745
+ readonly kind: "single" | "multi"
746
+ /** single only. */
747
+ readonly valueField?: string
748
+ /** multi only. */
749
+ readonly fields?: readonly string[]
750
+ /** single: options.length; multi: sum of every dimension's options. */
751
+ readonly optionCount: number
752
+ }
753
+
754
+ /** Lightweight directory of every picker catalog — no option payloads. */
755
+ export function summarizePickerCatalogs(): readonly PickerCatalogSummary[] {
756
+ return PICKER_CATALOGS.map((c) => ({
757
+ nodeType: c.nodeType,
758
+ label: c.label,
759
+ catalogId: c.catalogId,
760
+ kind: c.kind,
761
+ valueField: c.valueField,
762
+ fields: c.fields,
763
+ optionCount:
764
+ c.kind === "single"
765
+ ? (c.options?.length ?? 0)
766
+ : (c.dimensions?.reduce((n, d) => n + d.options.length, 0) ?? 0),
767
+ }))
768
+ }
769
+
770
+ export type PickerCatalogDetail = "compact" | "full"
771
+
772
+ export interface ProjectPickerCatalogOptions {
773
+ /** Default "compact" (id/label/category/icon). "full" adds description + promptHint. */
774
+ readonly detail?: PickerCatalogDetail
775
+ /** single-dim: keep only options in this category. */
776
+ readonly category?: string
777
+ /** multi-dim: keep only this dimension field. */
778
+ readonly field?: string
779
+ }
780
+
781
+ /** An option after projection — description/promptHint present only when detail="full". */
782
+ export interface ProjectedPickerOption {
783
+ readonly id: string
784
+ readonly label: string
785
+ readonly description?: string
786
+ readonly category?: string
787
+ readonly promptHint?: string
788
+ readonly icon?: string
789
+ }
790
+
791
+ export interface ProjectedPickerDimension {
792
+ readonly field: string
793
+ readonly label: string
794
+ readonly options: readonly ProjectedPickerOption[]
795
+ }
796
+
797
+ export interface ProjectedPickerCatalog {
798
+ readonly nodeType: string
799
+ readonly label: string
800
+ readonly catalogId: string
801
+ readonly kind: "single" | "multi"
802
+ readonly valueField?: string
803
+ readonly defaultValue?: string
804
+ readonly categoryOrder?: readonly string[]
805
+ readonly categoryLabels?: Readonly<Record<string, string>>
806
+ readonly options?: readonly ProjectedPickerOption[]
807
+ readonly fields?: readonly string[]
808
+ readonly dimensions?: readonly ProjectedPickerDimension[]
809
+ readonly detail: PickerCatalogDetail
810
+ }
811
+
812
+ function projectOption(o: PickerOption, detail: PickerCatalogDetail): ProjectedPickerOption {
813
+ return detail === "full"
814
+ ? {
815
+ id: o.id,
816
+ label: o.label,
817
+ description: o.description,
818
+ category: o.category,
819
+ promptHint: o.promptHint,
820
+ icon: o.icon,
821
+ }
822
+ : { id: o.id, label: o.label, category: o.category, icon: o.icon }
823
+ }
824
+
825
+ /** Project a catalog to the wire shape: compact by default, optional category/field filter. */
826
+ export function projectPickerCatalog(
827
+ c: PickerCatalog,
828
+ opts: ProjectPickerCatalogOptions = {},
829
+ ): ProjectedPickerCatalog {
830
+ const detail: PickerCatalogDetail = opts.detail ?? "compact"
831
+ const base = {
832
+ nodeType: c.nodeType,
833
+ label: c.label,
834
+ catalogId: c.catalogId,
835
+ kind: c.kind,
836
+ valueField: c.valueField,
837
+ defaultValue: c.defaultValue,
838
+ categoryOrder: c.categoryOrder,
839
+ categoryLabels: c.categoryLabels,
840
+ detail,
841
+ }
842
+ if (c.kind === "single") {
843
+ let options = c.options ?? []
844
+ if (opts.category) options = options.filter((o) => o.category === opts.category)
845
+ return { ...base, options: options.map((o) => projectOption(o, detail)) }
846
+ }
847
+ let dims = c.dimensions ?? []
848
+ if (opts.field) dims = dims.filter((d) => d.field === opts.field)
849
+ return {
850
+ ...base,
851
+ fields: c.fields,
852
+ dimensions: dims.map((d) => ({
853
+ field: d.field,
854
+ label: d.label,
855
+ options: d.options.map((o) => projectOption(o, detail)),
856
+ })),
857
+ }
858
+ }