@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,374 @@
1
+ import { z } from "zod"
2
+ import {
3
+ PEOPLE,
4
+ PERSON_DIMENSION_ORDER,
5
+ PERSON_DIMENSION_LABELS,
6
+ PERSON_FIELD_BY_DIMENSION,
7
+ getPersonDimensionLimit,
8
+ } from "./person.js"
9
+ import { STYLINGS, STYLING_DIMENSION_ORDER, STYLING_DIMENSION_LABELS, STYLING_FIELD_BY_DIMENSION, getStylingDimensionLimit } from "./styling.js"
10
+ import { FRAMINGS, FRAMING_CATEGORY_ORDER, FRAMING_CATEGORY_LABELS, FRAMING_FIELD_BY_CATEGORY, getFramingCategoryLimit } from "./framing.js"
11
+ import { LENSES } from "./lens.js"
12
+ import { CAMERA_FORMATS } from "./camera-format.js"
13
+
14
+ // ─── Descriptor model ────────────────────────────────────────────────────────
15
+
16
+ /** A catalog entry as the analyzer consumes it. Flat catalogs lack
17
+ * dimension/category; discriminated catalogs carry exactly one. */
18
+ interface AnalyzerEntry {
19
+ readonly id: string
20
+ readonly label: string
21
+ readonly description: string
22
+ readonly dimension?: string
23
+ readonly category?: string
24
+ }
25
+
26
+ export type PickerApplyMode = "override" | "overwrite-detected" | "fill-empty"
27
+ type ApplyCleanup = (patch: Record<string, unknown>, mode: PickerApplyMode) => void
28
+
29
+ /** Describes how to build an analyzer spec for one picker type. Three shapes:
30
+ * - "discriminated": ONE catalog whose entries carry `dimension` or `category`;
31
+ * `order`/`fieldByKey`/`labels` translate keys → fields/labels; `limitFn`
32
+ * gives the per-key cardinality.
33
+ * - "flat": a single-value catalog (lens, camera-format) → one limit-1
34
+ * dimension whose key == the node-data field. */
35
+ export type PickerAnalyzerDescriptor =
36
+ | {
37
+ readonly kind: "discriminated"
38
+ readonly toolName: string
39
+ readonly discriminator: "dimension" | "category"
40
+ readonly order: ReadonlyArray<string>
41
+ readonly fieldByKey: Readonly<Record<string, string>>
42
+ readonly labels: Readonly<Record<string, string>>
43
+ readonly entries: ReadonlyArray<AnalyzerEntry>
44
+ readonly limitFn: (key: string) => number
45
+ readonly excludedIds?: ReadonlySet<string>
46
+ readonly cleanup?: ApplyCleanup
47
+ }
48
+ | {
49
+ readonly kind: "flat"
50
+ readonly toolName: string
51
+ readonly field: string
52
+ readonly label: string
53
+ readonly entries: ReadonlyArray<AnalyzerEntry>
54
+ }
55
+
56
+ // ─── Registry (person only in Task 2; +4 in Task 3) ─────────────────────────
57
+
58
+ const PERSON_EXCLUDED = new Set<string>(["age-custom"])
59
+
60
+ const personCleanup: ApplyCleanup = (patch, mode) => {
61
+ if (mode === "override") {
62
+ patch.customAge = undefined
63
+ patch.lips = undefined
64
+ } else if ("age" in patch && patch.age !== "age-custom") {
65
+ patch.customAge = undefined
66
+ }
67
+ }
68
+
69
+ export const PICKER_ANALYZER_REGISTRY = {
70
+ person: {
71
+ kind: "discriminated",
72
+ toolName: "emit_person",
73
+ discriminator: "dimension",
74
+ order: PERSON_DIMENSION_ORDER as ReadonlyArray<string>,
75
+ fieldByKey: PERSON_FIELD_BY_DIMENSION as Readonly<Record<string, string>>,
76
+ labels: PERSON_DIMENSION_LABELS as Readonly<Record<string, string>>,
77
+ entries: PEOPLE as ReadonlyArray<AnalyzerEntry>,
78
+ limitFn: (k) => getPersonDimensionLimit(k as never),
79
+ excludedIds: PERSON_EXCLUDED,
80
+ cleanup: personCleanup,
81
+ },
82
+ styling: {
83
+ kind: "discriminated",
84
+ toolName: "emit_styling",
85
+ discriminator: "dimension",
86
+ order: STYLING_DIMENSION_ORDER as ReadonlyArray<string>,
87
+ fieldByKey: STYLING_FIELD_BY_DIMENSION as Readonly<Record<string, string>>,
88
+ labels: STYLING_DIMENSION_LABELS as Readonly<Record<string, string>>,
89
+ entries: STYLINGS as ReadonlyArray<AnalyzerEntry>,
90
+ limitFn: (k) => getStylingDimensionLimit(k as never),
91
+ },
92
+ framing: {
93
+ kind: "discriminated",
94
+ toolName: "emit_framing",
95
+ discriminator: "category",
96
+ order: FRAMING_CATEGORY_ORDER as ReadonlyArray<string>,
97
+ fieldByKey: FRAMING_FIELD_BY_CATEGORY as Readonly<Record<string, string>>,
98
+ labels: FRAMING_CATEGORY_LABELS as Readonly<Record<string, string>>,
99
+ entries: FRAMINGS as ReadonlyArray<AnalyzerEntry>,
100
+ limitFn: (k) => getFramingCategoryLimit(k as never),
101
+ },
102
+ lens: {
103
+ kind: "flat",
104
+ toolName: "emit_lens",
105
+ field: "lens",
106
+ label: "Lens",
107
+ entries: LENSES as ReadonlyArray<AnalyzerEntry>,
108
+ },
109
+ "camera-format": {
110
+ kind: "flat",
111
+ toolName: "emit_camera_format",
112
+ field: "cameraFormat",
113
+ label: "Camera / Film Stock",
114
+ entries: CAMERA_FORMATS as ReadonlyArray<AnalyzerEntry>,
115
+ },
116
+ } satisfies Record<string, PickerAnalyzerDescriptor>
117
+
118
+ export type PickerType = keyof typeof PICKER_ANALYZER_REGISTRY
119
+ export const PICKER_TYPES = Object.keys(PICKER_ANALYZER_REGISTRY) as PickerType[]
120
+ export const ANALYZABLE_PICKER_TYPES: ReadonlySet<string> = new Set(PICKER_TYPES)
121
+ export function isAnalyzablePicker(t: string): t is PickerType {
122
+ return ANALYZABLE_PICKER_TYPES.has(t)
123
+ }
124
+
125
+ // ─── Spec model ──────────────────────────────────────────────────────────────
126
+
127
+ export interface PickerDimensionSpec {
128
+ /** Catalog dimension id, e.g. "hair-color". Also the JSON key in the emitted pickerJson. */
129
+ readonly dimension: string
130
+ /** Target node-data field, e.g. "hairColor". */
131
+ readonly field: string
132
+ /** Human label/description, for the system-prompt legend. */
133
+ readonly label: string
134
+ /** 1 = single select; 2/3 = array with maxItems. */
135
+ readonly limit: number
136
+ /** Allowed catalog entry ids (the forced enum). */
137
+ readonly entryIds: ReadonlyArray<string>
138
+ /** id → human label/description, for the system-prompt legend. */
139
+ readonly legend: ReadonlyArray<{ id: string; label: string; description: string }>
140
+ }
141
+
142
+ export interface PickerAnalyzerSpec {
143
+ readonly pickerType: PickerType
144
+ readonly toolName: string
145
+ readonly dimensions: ReadonlyArray<PickerDimensionSpec>
146
+ readonly cleanup?: ApplyCleanup
147
+ }
148
+
149
+ export function buildPickerAnalyzerSpec(pickerType: PickerType): PickerAnalyzerSpec {
150
+ const d = PICKER_ANALYZER_REGISTRY[pickerType] as PickerAnalyzerDescriptor
151
+ if (d.kind === "flat") {
152
+ return {
153
+ pickerType,
154
+ toolName: d.toolName,
155
+ dimensions: [
156
+ {
157
+ dimension: d.field,
158
+ field: d.field,
159
+ label: d.label,
160
+ limit: 1,
161
+ entryIds: d.entries.map((e) => e.id),
162
+ legend: d.entries.map((e) => ({ id: e.id, label: e.label, description: e.description })),
163
+ },
164
+ ],
165
+ }
166
+ }
167
+ const dimensions: PickerDimensionSpec[] = d.order.map((key) => {
168
+ const entries = d.entries.filter(
169
+ (e) =>
170
+ (d.discriminator === "dimension" ? e.dimension : e.category) === key &&
171
+ !(d.excludedIds?.has(e.id)),
172
+ )
173
+ return {
174
+ dimension: key,
175
+ field: d.fieldByKey[key],
176
+ label: d.labels[key] ?? key,
177
+ limit: d.limitFn(key),
178
+ entryIds: entries.map((e) => e.id),
179
+ legend: entries.map((e) => ({ id: e.id, label: e.label, description: e.description })),
180
+ }
181
+ })
182
+ return { pickerType, toolName: d.toolName, dimensions, cleanup: d.cleanup }
183
+ }
184
+
185
+ // ─── Zod schema / legend / analyzer cache (unchanged logic) ──────────────────
186
+
187
+ /** Zod object: each dimension is an optional enum (single) or capped enum array
188
+ * (multi). `.strict()` blocks unknown keys. Mirrors the field cardinality. */
189
+ export function buildPickerZodSchema(
190
+ spec: PickerAnalyzerSpec,
191
+ ): z.ZodType<Record<string, string | string[]>, z.ZodTypeDef, unknown> {
192
+ const shape: Record<string, z.ZodTypeAny> = {}
193
+ for (const d of spec.dimensions) {
194
+ const ids = d.entryIds as unknown as [string, ...string[]]
195
+ const enumZ = z.enum(ids)
196
+ shape[d.dimension] = d.limit > 1 ? z.array(enumZ).max(d.limit).optional() : enumZ.optional()
197
+ }
198
+ // dynamic shape → Zod can't infer the narrowed output type; runtime-validated by picker-analyzer-registry.test.ts
199
+ return z.object(shape).strict() as unknown as z.ZodType<
200
+ Record<string, string | string[]>,
201
+ z.ZodTypeDef,
202
+ unknown
203
+ >
204
+ }
205
+
206
+ export interface PickerAnalyzer {
207
+ readonly spec: PickerAnalyzerSpec
208
+ readonly schema: z.ZodType<Record<string, string | string[]>, z.ZodTypeDef, unknown>
209
+ readonly legend: string
210
+ }
211
+
212
+ const ANALYZER_CACHE = new Map<PickerType, PickerAnalyzer>()
213
+
214
+ /** Memoized analyzer build: spec → Zod schema → legend, computed once per
215
+ * picker type and cached at module level. The three artifacts are catalog-
216
+ * derived and stable, so the per-request route handler can reuse them instead
217
+ * of rebuilding all three on every analysis call. */
218
+ export function getPickerAnalyzer(pickerType: PickerType): PickerAnalyzer {
219
+ const cached = ANALYZER_CACHE.get(pickerType)
220
+ if (cached) return cached
221
+ const spec = buildPickerAnalyzerSpec(pickerType)
222
+ const analyzer: PickerAnalyzer = {
223
+ spec,
224
+ schema: buildPickerZodSchema(spec),
225
+ legend: buildPickerLegend(spec),
226
+ }
227
+ ANALYZER_CACHE.set(pickerType, analyzer)
228
+ return analyzer
229
+ }
230
+
231
+ // ─── Gaps + multi-picker union spec ──────────────────────────────────────────
232
+
233
+ export const GAPS_SCHEMA = z
234
+ .object({
235
+ missingItems: z
236
+ .array(
237
+ z.object({
238
+ picker: z.string(),
239
+ dimension: z.string(),
240
+ observed: z.string().max(120),
241
+ }),
242
+ )
243
+ .max(8)
244
+ .default([]),
245
+ missingCategories: z
246
+ .array(
247
+ z.object({
248
+ picker: z.string(),
249
+ suggestedDimension: z.string(),
250
+ observed: z.string().max(120),
251
+ }),
252
+ )
253
+ .max(8)
254
+ .default([]),
255
+ })
256
+ .default({ missingItems: [], missingCategories: [] })
257
+
258
+ export interface PickerGaps {
259
+ readonly missingItems: ReadonlyArray<{ picker: string; dimension: string; observed: string }>
260
+ readonly missingCategories: ReadonlyArray<{
261
+ picker: string
262
+ suggestedDimension: string
263
+ observed: string
264
+ }>
265
+ }
266
+
267
+ export interface MultiPickerAnalyzerSpec {
268
+ readonly schema: z.ZodType<Record<string, unknown>, z.ZodTypeDef, unknown>
269
+ readonly toolName: string
270
+ readonly legend: string
271
+ }
272
+
273
+ const MULTI_CACHE = new Map<string, MultiPickerAnalyzerSpec>()
274
+
275
+ /** Build ONE forced-tool schema spanning the given pickers (each section
276
+ * optional so an omitted picker doesn't trigger a validation retry) plus the
277
+ * capped `gaps` sidecar. Memoized by the sorted picker-set key. */
278
+ export function buildMultiPickerAnalyzerSpec(types: ReadonlyArray<PickerType>): MultiPickerAnalyzerSpec {
279
+ const sorted = [...new Set(types)].sort()
280
+ const key = sorted.join(",")
281
+ const cached = MULTI_CACHE.get(key)
282
+ if (cached) return cached
283
+
284
+ const shape: Record<string, z.ZodTypeAny> = {}
285
+ const legendParts: string[] = []
286
+ for (const t of sorted) {
287
+ const spec = buildPickerAnalyzerSpec(t)
288
+ shape[t] = buildPickerZodSchema(spec).optional()
289
+ legendParts.push(`# ${t.toUpperCase()} PICKER\n${buildPickerLegend(spec)}`)
290
+ }
291
+ shape.gaps = GAPS_SCHEMA
292
+ const result: MultiPickerAnalyzerSpec = {
293
+ schema: z.object(shape).strict() as unknown as MultiPickerAnalyzerSpec["schema"],
294
+ toolName: "emit_pickers",
295
+ legend: legendParts.join("\n\n"),
296
+ }
297
+ MULTI_CACHE.set(key, result)
298
+ return result
299
+ }
300
+
301
+ /** Human-readable legend appended to the system prompt so enum ids are meaningful. */
302
+ export function buildPickerLegend(spec: PickerAnalyzerSpec): string {
303
+ const lines: string[] = []
304
+ for (const d of spec.dimensions) {
305
+ const cap = d.limit > 1 ? `up to ${d.limit}` : "one"
306
+ lines.push(`## ${d.label} (key: "${d.dimension}", choose ${cap})`)
307
+ for (const e of d.legend) {
308
+ lines.push(`- ${e.id}: ${e.label}${e.description ? ` — ${e.description}` : ""}`)
309
+ }
310
+ }
311
+ return lines.join("\n")
312
+ }
313
+
314
+ // ─── applyPickerJson (cleanup now via spec, not hardcoded person) ────────────
315
+
316
+ function isEmptyValue(v: unknown): boolean {
317
+ if (v === undefined || v === null) return true
318
+ if (Array.isArray(v)) return v.length === 0
319
+ return String(v).length === 0
320
+ }
321
+
322
+ function coerce(value: unknown, limit: number): string | string[] {
323
+ const arr = Array.isArray(value) ? value.map(String) : [String(value)]
324
+ return limit > 1 ? arr.slice(0, limit) : arr[0]
325
+ }
326
+
327
+ /**
328
+ * Produces the patch to merge into the picker node's data. Touches ONLY the
329
+ * dimension fields (never label/preText/postText/maxItemsPerRow). `override`
330
+ * also clears undetected dimension fields, and runs the picker's `cleanup`
331
+ * (e.g. person resets customAge + clears the deprecated `lips` field).
332
+ */
333
+ export function applyPickerJson(
334
+ current: Record<string, unknown>,
335
+ pickerJson: Record<string, unknown>,
336
+ mode: PickerApplyMode,
337
+ spec: PickerAnalyzerSpec,
338
+ ): Record<string, unknown> {
339
+ const patch: Record<string, unknown> = {}
340
+ for (const d of spec.dimensions) {
341
+ const incoming = pickerJson[d.dimension]
342
+ const present = !isEmptyValue(incoming)
343
+ if (mode === "override") {
344
+ patch[d.field] = present ? coerce(incoming, d.limit) : undefined
345
+ } else if (mode === "overwrite-detected") {
346
+ if (present) patch[d.field] = coerce(incoming, d.limit)
347
+ } else {
348
+ if (present && isEmptyValue(current[d.field])) patch[d.field] = coerce(incoming, d.limit)
349
+ }
350
+ }
351
+ spec.cleanup?.(patch, mode)
352
+ return patch
353
+ }
354
+
355
+ // ─── Edge-derived fan-out selection (shared by frontend + backend) ───────────
356
+
357
+ /** The analyzable picker node types wired to a producer's `picker-json` output
358
+ * (deduped). The ONE definition of edge-derived selection; the frontend
359
+ * execute path and the backend orchestrator both call this so they can't
360
+ * drift. Accepts minimal structural node/edge shapes so both layers' richer
361
+ * types satisfy it. */
362
+ export function pickerFanoutTargets(
363
+ producerId: string,
364
+ edges: ReadonlyArray<{ source: string; target: string; sourceHandle?: string | null }>,
365
+ nodes: ReadonlyArray<{ id: string; type?: string }>,
366
+ ): PickerType[] {
367
+ const out = new Set<string>()
368
+ for (const e of edges) {
369
+ if (e.source !== producerId || e.sourceHandle !== "picker-json") continue
370
+ const t = nodes.find((n) => n.id === e.target)?.type
371
+ if (t && isAnalyzablePicker(t)) out.add(t)
372
+ }
373
+ return [...out] as PickerType[]
374
+ }