@nodaro/prompts 1.7.3 → 1.8.0

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 (72) hide show
  1. package/dist/index.cjs +5422 -4269
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1198 -61
  4. package/dist/index.d.ts +1198 -61
  5. package/dist/index.js +5336 -4271
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/b7-person-pack-e2e.test.ts +37 -0
  9. package/src/__tests__/catalog-funnel-ratchet.test.ts +97 -0
  10. package/src/__tests__/catalog-packs.test.ts +130 -0
  11. package/src/__tests__/catalog-sidecar-coverage.test.ts +27 -0
  12. package/src/__tests__/catalog-terms.test.ts +162 -0
  13. package/src/__tests__/character-default-role.test.ts +3 -2
  14. package/src/__tests__/content-free-contract.test.ts +45 -0
  15. package/src/__tests__/dod-replace-pack-acceptance.test.ts +41 -0
  16. package/src/__tests__/fixtures/parameter-hint-golden.json +2959 -0
  17. package/src/__tests__/fixtures/person-sector-pack.ts +27 -0
  18. package/src/__tests__/i18n-entry-completeness.test.ts +5 -2
  19. package/src/__tests__/parameter-hint-mode.test.ts +385 -0
  20. package/src/__tests__/parameter-prompt-hint-pack-fallback.test.ts +25 -0
  21. package/src/__tests__/person-packs.test.ts +166 -0
  22. package/src/__tests__/project-all-catalogs.test.ts +26 -0
  23. package/src/__tests__/prompt-builder.test.ts +53 -0
  24. package/src/__tests__/registered-catalogs-funnel.test.ts +30 -0
  25. package/src/__tests__/registered-catalogs-guard.test.ts +11 -0
  26. package/src/__tests__/term.test.ts +104 -0
  27. package/src/__tests__/transitions.test.ts +8 -5
  28. package/src/__tests__/upstream-immutability.test.ts +23 -0
  29. package/src/action-fx.ts +67 -17
  30. package/src/aesthetic.ts +54 -6
  31. package/src/atmosphere.ts +64 -23
  32. package/src/backdrop.ts +44 -30
  33. package/src/camera-format.ts +33 -11
  34. package/src/camera-motions.ts +89 -0
  35. package/src/catalog-packs.ts +125 -0
  36. package/src/catalog-sidecar-coverage.ts +36 -0
  37. package/src/character-fx.ts +112 -39
  38. package/src/color-look.ts +44 -27
  39. package/src/composition-effects.ts +21 -7
  40. package/src/era.ts +24 -0
  41. package/src/exposure-settings.ts +76 -18
  42. package/src/framing.ts +100 -0
  43. package/src/held-prop.ts +125 -63
  44. package/src/identity-lock.ts +12 -5
  45. package/src/image-reference-doctrine.ts +55 -0
  46. package/src/index.ts +5 -0
  47. package/src/instrumentation.ts +148 -57
  48. package/src/lens.ts +31 -15
  49. package/src/lighting.ts +120 -59
  50. package/src/loop-subject.ts +27 -1
  51. package/src/materials.ts +123 -69
  52. package/src/mood.ts +123 -51
  53. package/src/music-genre.ts +171 -67
  54. package/src/music-mood.ts +85 -17
  55. package/src/parameter-prompt-hint.ts +168 -56
  56. package/src/person-packs.ts +182 -0
  57. package/src/person.ts +506 -408
  58. package/src/photo-genre.ts +37 -22
  59. package/src/photographer.ts +138 -1
  60. package/src/picker-catalogs.ts +86 -40
  61. package/src/pose.ts +105 -40
  62. package/src/post-process-effects.ts +51 -8
  63. package/src/prompt-builder.ts +16 -1
  64. package/src/render-quality.ts +25 -7
  65. package/src/setting.ts +30 -14
  66. package/src/style.ts +33 -15
  67. package/src/styling.ts +155 -94
  68. package/src/temporal.ts +80 -18
  69. package/src/term.ts +155 -0
  70. package/src/transitions.ts +73 -26
  71. package/src/voice-character.ts +190 -90
  72. package/src/voice-delivery.ts +83 -12
@@ -21,14 +21,14 @@ export type { HintNodeLike, HintEdgeLike, HintGraphContext }
21
21
 
22
22
  import { buildFramingHints } from "./framing.js"
23
23
  import { buildLightingHints } from "./lighting.js"
24
- import { getLensPromptHint } from "./lens.js"
25
- import { getCameraFormatPromptHint } from "./camera-format.js"
26
- import { getColorLookPromptHint } from "./color-look.js"
24
+ import { getLensPromptHint, getLensTerm } from "./lens.js"
25
+ import { getCameraFormatPromptHint, getCameraFormatTerm } from "./camera-format.js"
26
+ import { getColorLookPromptHint, getColorLookTerm } from "./color-look.js"
27
27
  import { buildAtmosphereHints } from "./atmosphere.js"
28
28
  import { buildActionFxHints } from "./action-fx.js"
29
- import { getStylePromptHint } from "./style.js"
30
- import { getSettingPromptHint } from "./setting.js"
31
- import { getLoopSubjectPromptHint } from "./loop-subject.js"
29
+ import { getStylePromptHint, getStyleTerm } from "./style.js"
30
+ import { getSettingPromptHint, getSettingTerm } from "./setting.js"
31
+ import { getLoopSubjectPromptHint, getLoopSubjectTerm } from "./loop-subject.js"
32
32
  import { buildPersonHints } from "./person.js"
33
33
  import { buildMoodHints } from "./mood.js"
34
34
  import { buildPoseHints } from "./pose.js"
@@ -42,21 +42,23 @@ import { getAnimal } from "@nodaro/shared"
42
42
  import { getVehicle } from "@nodaro/shared"
43
43
  import { getWeapon } from "@nodaro/shared"
44
44
  import { getFurniture } from "@nodaro/shared"
45
- import { getPhotoGenrePromptHint } from "./photo-genre.js"
46
- import { getBackdropPromptHint } from "./backdrop.js"
45
+ import { getPhotoGenrePromptHint, getPhotoGenreTerm } from "./photo-genre.js"
46
+ import { getBackdropPromptHint, getBackdropTerm } from "./backdrop.js"
47
47
  import { buildHeldPropHints } from "./held-prop.js"
48
48
  import { buildPhotographerHints } from "./photographer.js"
49
49
  import { buildAestheticHints } from "./aesthetic.js"
50
- import { getEraPromptHint } from "./era.js"
50
+ import { getEraPromptHint, getEraTerm } from "./era.js"
51
51
  import { buildExposureHints } from "./exposure-settings.js"
52
- import { getRenderQualityPromptHint } from "./render-quality.js"
53
- import { getCompositionEffectPromptHint } from "./composition-effects.js"
52
+ import { getRenderQualityPromptHint, getRenderQualityTerm } from "./render-quality.js"
53
+ import { getCompositionEffectPromptHint, getCompositionEffectTerm } from "./composition-effects.js"
54
54
  import { buildPostProcessHints } from "./post-process-effects.js"
55
55
  import { buildMusicGenreHints } from "./music-genre.js"
56
56
  import { buildMusicMoodHints } from "./music-mood.js"
57
57
  import { buildInstrumentationHints } from "./instrumentation.js"
58
58
  import { buildVoiceCharacterHints } from "./voice-character.js"
59
59
  import { buildVoiceDeliveryHints } from "./voice-delivery.js"
60
+ import { getPickerCatalog } from "./picker-catalogs.js"
61
+ import { deriveTerm, resolveTerm, type PickerHintMode } from "./term.js"
60
62
 
61
63
 
62
64
  function asStr(v: unknown): string {
@@ -88,21 +90,87 @@ function withCustomText(data: Record<string, unknown>, mainHint: string): string
88
90
  return fragments.join(", ")
89
91
  }
90
92
 
93
+ /**
94
+ * The node-level hint mode a picker declares via `data.hintMode`.
95
+ *
96
+ * Only the two documented values count; anything else (a stale value, a typo,
97
+ * a non-string) is treated as UNDECLARED and falls back — to the inherited
98
+ * mode when the node is resolved as an upstream input, and to `"full"`
99
+ * otherwise. Compact is opt-in, so an unrecognized value can never silently
100
+ * shorten a prompt.
101
+ */
102
+ function readHintMode(data: Record<string, unknown>): PickerHintMode | undefined {
103
+ const raw = data.hintMode
104
+ return raw === "compact" || raw === "full" ? raw : undefined
105
+ }
106
+
107
+ /** Pick the full-hint or the compact-term getter for the active mode. */
108
+ function byMode<T>(mode: PickerHintMode, full: T, compact: T): T {
109
+ return mode === "compact" ? compact : full
110
+ }
111
+
112
+ /**
113
+ * The compact fragment for an OBJECT-entity entry (animal / vehicle / weapon /
114
+ * furniture). Those catalogs carry no `promptHint` of their own — the full
115
+ * fragment is synthesized as "featuring a {label}, {description}" — so the
116
+ * compact form is the authored `term` when there is one and the derived label
117
+ * otherwise (a concrete object's label IS its trade term). The framing verb
118
+ * ("featuring a", "with a") belongs to the HINT; a term drops bare into
119
+ * whatever sentence the consumer is building, and "the object is in the scene"
120
+ * is precisely what these four nodes mean.
121
+ *
122
+ * The fallback MUST be `deriveTerm` and not a bare `toLowerCase()`: it is the
123
+ * same fallback `objectOptions` uses to build the `/v1/catalogs` projection,
124
+ * so a parenthetical label ("Rifle (bolt-action)") must strip identically here
125
+ * or the injected fragment and the projected term would disagree.
126
+ *
127
+ * `term` is read structurally because it is being added to the shared entity
128
+ * interfaces separately; this stays correct before and after that lands.
129
+ */
130
+ function objectEntityTerm(entry: { readonly label: string }): string {
131
+ return (entry as { term?: string }).term ?? deriveTerm(entry.label)
132
+ }
133
+
91
134
  /**
92
135
  * Dispatch by parameter-node type to its prompt-hint string. For camera-motion,
93
136
  * pass `ctx` to include the composed start/end clauses; otherwise only the
94
137
  * bare motion description is returned.
138
+ *
139
+ * VERBOSITY — a picker node may set `data.hintMode` to `"compact"` to inject
140
+ * its short professional `term` ("whip pan left", "hard cut") instead of the
141
+ * long `promptHint`. Absent, or any unrecognized value, means `"full"`, whose
142
+ * output is byte-identical to what this function returned before hint modes
143
+ * existed. ONLY the base catalog fragment swaps: `preText`/`postText`, the
144
+ * transition / camera-motion / character-fx timing and start-state/end-state
145
+ * clauses, multi-pick joining and multi-dimension composition are emitted the
146
+ * same way in both modes.
95
147
  */
96
148
  export function getParameterPromptHint(
97
149
  node: HintNodeLike | undefined,
98
150
  ctx?: HintGraphContext,
151
+ ): string {
152
+ return resolveParameterHint(node, ctx)
153
+ }
154
+
155
+ /**
156
+ * The dispatch body. `inherited` carries the mode DOWN into the nodes wired to
157
+ * a composer's `startState` / `endState` handles, so a compact transition
158
+ * composes compact start/end clauses rather than mixing a term with two
159
+ * paragraphs. A node that declares its own `hintMode` still wins over what it
160
+ * inherits.
161
+ */
162
+ function resolveParameterHint(
163
+ node: HintNodeLike | undefined,
164
+ ctx?: HintGraphContext,
165
+ inherited?: PickerHintMode,
99
166
  ): string {
100
167
  if (!node?.type) return ""
101
168
  const data = (node.data ?? {}) as Record<string, unknown>
169
+ const mode: PickerHintMode = readHintMode(data) ?? inherited ?? "full"
102
170
 
103
171
  if (node.type === "camera-motion") {
104
172
  const motionId = asStr(data.cameraMotion) || undefined
105
- if (!ctx) return withCustomText(data, composeCameraMotionHintFromConnections(motionId, [], []))
173
+ if (!ctx) return withCustomText(data, composeCameraMotionHintFromConnections(motionId, [], [], mode))
106
174
  const startHints: string[] = []
107
175
  const endHints: string[] = []
108
176
  for (const edge of ctx.edges) {
@@ -111,13 +179,14 @@ export function getParameterPromptHint(
111
179
  if (!src) continue
112
180
  // Pass no ctx for nested resolution: startState/endState inputs are
113
181
  // themselves parameter nodes (framing/tone/etc.) that don't need graph
114
- // context, and avoiding recursion keeps the walk cycle-safe.
115
- const hint = getParameterPromptHint(src)
182
+ // context, and avoiding recursion keeps the walk cycle-safe. The mode
183
+ // rides down so the composed clause stays at one level of detail.
184
+ const hint = resolveParameterHint(src, undefined, mode)
116
185
  if (!hint) continue
117
186
  if (edge.targetHandle === "startState") startHints.push(hint)
118
187
  else if (edge.targetHandle === "endState") endHints.push(hint)
119
188
  }
120
- return withCustomText(data, composeCameraMotionHintFromConnections(motionId, startHints, endHints))
189
+ return withCustomText(data, composeCameraMotionHintFromConnections(motionId, startHints, endHints, mode))
121
190
  }
122
191
 
123
192
  if (node.type === "transition") {
@@ -132,7 +201,7 @@ export function getParameterPromptHint(
132
201
  intensity: asStr(data.intensity) as TransitionIntensity | undefined,
133
202
  }
134
203
  if (!ctx) {
135
- return withCustomText(data, composeTransitionHintFromConnections(transitionId, [], [], timing))
204
+ return withCustomText(data, composeTransitionHintFromConnections(transitionId, [], [], timing, mode))
136
205
  }
137
206
  const startHints: string[] = []
138
207
  const endHints: string[] = []
@@ -140,12 +209,12 @@ export function getParameterPromptHint(
140
209
  if (edge.target !== node.id) continue
141
210
  const src = ctx.nodes.find((n) => n.id === edge.source)
142
211
  if (!src) continue
143
- const hint = getParameterPromptHint(src) // no ctx — cycle-safe
212
+ const hint = resolveParameterHint(src, undefined, mode) // no ctx — cycle-safe
144
213
  if (!hint) continue
145
214
  if (edge.targetHandle === "startState") startHints.push(hint)
146
215
  else if (edge.targetHandle === "endState") endHints.push(hint)
147
216
  }
148
- return withCustomText(data, composeTransitionHintFromConnections(transitionId, startHints, endHints, timing))
217
+ return withCustomText(data, composeTransitionHintFromConnections(transitionId, startHints, endHints, timing, mode))
149
218
  }
150
219
 
151
220
  if (node.type === "character-fx") {
@@ -160,7 +229,7 @@ export function getParameterPromptHint(
160
229
  intensity: asStr(data.intensity) as CharacterFxIntensity | undefined,
161
230
  }
162
231
  if (!ctx) {
163
- return withCustomText(data, composeCharacterFxHintFromConnections(effectId, [], timing))
232
+ return withCustomText(data, composeCharacterFxHintFromConnections(effectId, [], timing, mode))
164
233
  }
165
234
  const targetNames: string[] = []
166
235
  for (const edge of ctx.edges) {
@@ -171,106 +240,149 @@ export function getParameterPromptHint(
171
240
  const name = extractCharacterRefName(src)
172
241
  if (name) targetNames.push(name)
173
242
  }
174
- return withCustomText(data, composeCharacterFxHintFromConnections(effectId, targetNames, timing))
243
+ return withCustomText(data, composeCharacterFxHintFromConnections(effectId, targetNames, timing, mode))
175
244
  }
176
245
 
177
- switch (node.type) {
246
+ const base = resolveBaseHint(node.type, data, mode)
247
+ if (base) return base
248
+ // Pack-extend fallback: a single-dim pack entry the per-catalog getter (which
249
+ // reads the frozen base array) can't resolve. Resolve it against the
250
+ // registered (pack-composed) catalog's options. Composition already resolved
251
+ // `PickerOption.term`; `resolveTerm` is idempotent over a resolved option and
252
+ // is called anyway so a pack option that reached this array by some other
253
+ // route still injects SOMETHING in compact mode rather than `undefined`.
254
+ const cat = getPickerCatalog(node.type)
255
+ if (cat?.kind === "single" && cat.valueField) {
256
+ const id = typeof data[cat.valueField] === "string" ? (data[cat.valueField] as string) : ""
257
+ const opt = cat.options?.find((o) => o.id === id)
258
+ if (opt) return byMode(mode, opt.promptHint, resolveTerm(opt))
259
+ }
260
+ return base
261
+ }
262
+
263
+ /**
264
+ * Base (upstream) hint dispatch by node type — the per-catalog getters read the
265
+ * frozen base arrays. Pack-added single-dim ids are resolved by the caller
266
+ * against the registered (pack-composed) catalog.
267
+ *
268
+ * `mode` selects the base fragment only: the compact `get<Name>Term` getter /
269
+ * `build<Name>Terms` builder instead of the verbose one. The `withCustomText`
270
+ * wrapper, the builders' own pre/post composition, and the free-text node
271
+ * types (tone / style-guide / text-prompt — user prose, not catalog copy) are
272
+ * identical in both modes.
273
+ */
274
+ function resolveBaseHint(
275
+ type: string,
276
+ data: Record<string, unknown>,
277
+ mode: PickerHintMode = "full",
278
+ ): string {
279
+ switch (type) {
178
280
  case "framing":
179
- return withCustomText(data, buildFramingHints(data).join(", "))
281
+ return withCustomText(data, buildFramingHints(data, false, mode).join(", "))
180
282
  case "lighting":
181
- return withCustomText(data, buildLightingHints(data).join(", "))
283
+ return withCustomText(data, buildLightingHints(data, mode).join(", "))
182
284
  case "lens":
183
- return withCustomText(data, getLensPromptHint(asStr(data.lens)))
285
+ return withCustomText(data, byMode(mode, getLensPromptHint, getLensTerm)(asStr(data.lens)))
184
286
  case "camera-format":
185
- return withCustomText(data, getCameraFormatPromptHint(asStr(data.cameraFormat)))
287
+ return withCustomText(data, byMode(mode, getCameraFormatPromptHint, getCameraFormatTerm)(asStr(data.cameraFormat)))
186
288
  case "color-look":
187
- return withCustomText(data, getColorLookPromptHint(asStr(data.colorLook)))
289
+ return withCustomText(data, byMode(mode, getColorLookPromptHint, getColorLookTerm)(asStr(data.colorLook)))
188
290
 
189
291
  // build*Hints in music-* / voice-* / mood / person / etc. compose
190
292
  // preText/postText internally — bypass the wrapper to avoid double-
191
293
  // composition.
192
294
  case "music-genre":
193
- return buildMusicGenreHints((data ?? {}) as Parameters<typeof buildMusicGenreHints>[0])
295
+ return buildMusicGenreHints((data ?? {}) as Parameters<typeof buildMusicGenreHints>[0], mode)
194
296
  case "music-mood":
195
- return buildMusicMoodHints((data ?? {}) as Parameters<typeof buildMusicMoodHints>[0])
297
+ return buildMusicMoodHints((data ?? {}) as Parameters<typeof buildMusicMoodHints>[0], mode)
196
298
  case "instrumentation":
197
- return buildInstrumentationHints((data ?? {}) as Parameters<typeof buildInstrumentationHints>[0])
299
+ return buildInstrumentationHints((data ?? {}) as Parameters<typeof buildInstrumentationHints>[0], mode)
198
300
  case "voice-character":
199
- return buildVoiceCharacterHints((data ?? {}) as Parameters<typeof buildVoiceCharacterHints>[0])
301
+ return buildVoiceCharacterHints((data ?? {}) as Parameters<typeof buildVoiceCharacterHints>[0], mode)
200
302
  case "voice-delivery":
201
- return buildVoiceDeliveryHints((data ?? {}) as Parameters<typeof buildVoiceDeliveryHints>[0])
303
+ return buildVoiceDeliveryHints((data ?? {}) as Parameters<typeof buildVoiceDeliveryHints>[0], mode)
202
304
  case "person":
203
- return buildPersonHints(data).join(", ")
305
+ return buildPersonHints(data, mode).join(", ")
204
306
  case "mood":
205
- return buildMoodHints(data).join(", ")
307
+ return buildMoodHints(data, mode).join(", ")
206
308
  case "pose":
207
- return buildPoseHints(data).join(", ")
309
+ return buildPoseHints(data, mode).join(", ")
208
310
  case "styling":
209
- return buildStylingHints(data).join(", ")
311
+ return buildStylingHints(data, mode).join(", ")
210
312
 
211
313
  case "atmosphere":
212
- return withCustomText(data, buildAtmosphereHints(data.atmosphere).join(", "))
314
+ return withCustomText(data, buildAtmosphereHints(data.atmosphere, mode).join(", "))
213
315
  case "action-fx":
214
- return withCustomText(data, buildActionFxHints(data.actionFx).join(", "))
316
+ return withCustomText(data, buildActionFxHints(data.actionFx, mode).join(", "))
215
317
  case "style":
216
- return withCustomText(data, getStylePromptHint(asStr(data.style)))
318
+ return withCustomText(data, byMode(mode, getStylePromptHint, getStyleTerm)(asStr(data.style)))
217
319
  case "setting":
218
- return withCustomText(data, getSettingPromptHint(asStr(data.setting)))
320
+ return withCustomText(data, byMode(mode, getSettingPromptHint, getSettingTerm)(asStr(data.setting)))
219
321
  case "loop-subject":
220
- return withCustomText(data, getLoopSubjectPromptHint(asStr(data.loopSubject)))
322
+ return withCustomText(data, byMode(mode, getLoopSubjectPromptHint, getLoopSubjectTerm)(asStr(data.loopSubject)))
221
323
  case "material":
222
- return withCustomText(data, buildMaterialHints(data.material))
324
+ return withCustomText(data, buildMaterialHints(data.material, mode))
223
325
  case "animal": {
224
326
  const animal = getAnimal(asStr(data.animal))
225
327
  return withCustomText(
226
328
  data,
227
- animal ? `featuring a ${animal.label.toLowerCase()}, ${animal.description}` : "",
329
+ animal
330
+ ? byMode(mode, `featuring a ${animal.label.toLowerCase()}, ${animal.description}`, objectEntityTerm(animal))
331
+ : "",
228
332
  )
229
333
  }
230
334
  case "vehicle": {
231
335
  const vehicle = getVehicle(asStr(data.vehicle))
232
336
  return withCustomText(
233
337
  data,
234
- vehicle ? `featuring a ${vehicle.label.toLowerCase()}, ${vehicle.description}` : "",
338
+ vehicle
339
+ ? byMode(mode, `featuring a ${vehicle.label.toLowerCase()}, ${vehicle.description}`, objectEntityTerm(vehicle))
340
+ : "",
235
341
  )
236
342
  }
237
343
  case "weapon": {
238
344
  const weapon = getWeapon(asStr(data.weapon))
239
345
  return withCustomText(
240
346
  data,
241
- weapon ? `with a ${weapon.label.toLowerCase()}, ${weapon.description}` : "",
347
+ weapon
348
+ ? byMode(mode, `with a ${weapon.label.toLowerCase()}, ${weapon.description}`, objectEntityTerm(weapon))
349
+ : "",
242
350
  )
243
351
  }
244
352
  case "furniture": {
245
353
  const furniture = getFurniture(asStr(data.furniture))
246
354
  return withCustomText(
247
355
  data,
248
- furniture ? `including a ${furniture.label.toLowerCase()}, ${furniture.description}` : "",
356
+ furniture
357
+ ? byMode(mode, `including a ${furniture.label.toLowerCase()}, ${furniture.description}`, objectEntityTerm(furniture))
358
+ : "",
249
359
  )
250
360
  }
251
361
  case "photo-genre":
252
- return withCustomText(data, getPhotoGenrePromptHint(asStr(data.photoGenre)))
362
+ return withCustomText(data, byMode(mode, getPhotoGenrePromptHint, getPhotoGenreTerm)(asStr(data.photoGenre)))
253
363
  case "backdrop":
254
- return withCustomText(data, getBackdropPromptHint(asStr(data.backdrop)))
364
+ return withCustomText(data, byMode(mode, getBackdropPromptHint, getBackdropTerm)(asStr(data.backdrop)))
255
365
  case "held-prop":
256
- return withCustomText(data, buildHeldPropHints(data.heldProp).join(", "))
366
+ return withCustomText(data, buildHeldPropHints(data.heldProp, mode).join(", "))
257
367
  case "photographer":
258
- return withCustomText(data, buildPhotographerHints(data.photographer))
368
+ return withCustomText(data, buildPhotographerHints(data.photographer, mode))
259
369
  case "aesthetic":
260
- return withCustomText(data, buildAestheticHints(data.aesthetic))
370
+ return withCustomText(data, buildAestheticHints(data.aesthetic, mode))
261
371
  case "era":
262
- return withCustomText(data, getEraPromptHint(asStr(data.era)))
372
+ return withCustomText(data, byMode(mode, getEraPromptHint, getEraTerm)(asStr(data.era)))
263
373
  case "temporal":
264
- return withCustomText(data, buildTemporalHints(data).join(", "))
374
+ return withCustomText(data, buildTemporalHints(data, mode).join(", "))
265
375
  case "exposure-settings":
266
- return withCustomText(data, buildExposureHints(data).join(", "))
376
+ return withCustomText(data, buildExposureHints(data, mode).join(", "))
267
377
  case "render-quality":
268
- return withCustomText(data, getRenderQualityPromptHint(asStr(data.renderQuality)))
378
+ return withCustomText(data, byMode(mode, getRenderQualityPromptHint, getRenderQualityTerm)(asStr(data.renderQuality)))
269
379
  case "composition-effects":
270
- return withCustomText(data, getCompositionEffectPromptHint(asStr(data.compositionEffect)))
380
+ return withCustomText(data, byMode(mode, getCompositionEffectPromptHint, getCompositionEffectTerm)(asStr(data.compositionEffect)))
271
381
  case "post-process-effects":
272
- return withCustomText(data, buildPostProcessHints(data.postProcess).join(", "))
382
+ return withCustomText(data, buildPostProcessHints(data.postProcess, mode).join(", "))
273
383
 
384
+ // Free text authored by the user, not catalog copy — there is no shorter
385
+ // professional form to swap in, so these are identical in both modes.
274
386
  case "tone":
275
387
  return asStr(data.tone).trim()
276
388
  case "style-guide":
@@ -0,0 +1,182 @@
1
+ import {
2
+ PEOPLE,
3
+ PERSON_DIMENSION_ORDER,
4
+ PERSON_DIMENSION_LABELS,
5
+ PERSON_FIELD_BY_DIMENSION,
6
+ type Person,
7
+ } from "./person.js"
8
+ import { registerCatalogPack, getRegisteredCatalogPacks } from "./catalog-packs.js"
9
+ import type { PickerDimension, PickerOption } from "./picker-catalogs.js"
10
+ import { resolveTerm } from "./term.js"
11
+ import { setRegisteredPersonPackFields } from "@nodaro/shared"
12
+
13
+ /**
14
+ * A person-pack entry. Pack dimensions are new keys OUTSIDE the closed
15
+ * `PersonDimension` union, so `dimension` is widened to `string`.
16
+ */
17
+ export type RegisteredPersonEntry = Omit<Person, "dimension"> & { dimension: string }
18
+
19
+ export interface PersonPack {
20
+ readonly id: string
21
+ readonly entries: readonly RegisteredPersonEntry[]
22
+ // Pack dimensions are single-select in Phase 0: the selection limit still
23
+ // comes from `getPersonDimensionLimit`, which only knows the base
24
+ // `PersonDimension` union (a pack key resolves to 1), so a per-dimension
25
+ // `maxSelected` would be silently ignored. Omitted rather than accepted-and-
26
+ // dropped; add it here AND in the limit lookup together when multi-select
27
+ // pack dimensions are wired.
28
+ readonly dimensions?: readonly { dimension: string; field: string; label: string }[]
29
+ readonly sidecars?: Parameters<typeof registerCatalogPack>[0]["sidecars"]
30
+ readonly exemptSidecarLocales?: Parameters<typeof registerCatalogPack>[0]["exemptSidecarLocales"]
31
+ }
32
+
33
+ let personPacks: PersonPack[] = []
34
+ let version = 0
35
+
36
+ /**
37
+ * Push the aggregate of every registered person pack's dimension data-field
38
+ * names into `@nodaro/shared`'s content-free registry, so
39
+ * `getParameterValue(data, "person")` resolves a pack dimension in the
40
+ * `{PersonLabel}` field-mapping fallback (G4). shared never imports prompts —
41
+ * the field list is pushed in here. Empty aggregate ⇒ mainline identity.
42
+ */
43
+ function syncPersonPackFields(): void {
44
+ setRegisteredPersonPackFields(
45
+ personPacks.flatMap((p) => (p.dimensions ?? []).map((d) => d.field)),
46
+ )
47
+ }
48
+
49
+ export function registerPersonPack(pack: PersonPack): void {
50
+ if (personPacks.some((p) => p.id === pack.id)) throw new Error(`duplicate person pack id "${pack.id}"`)
51
+ personPacks = [...personPacks, pack]
52
+ version++
53
+ // Fan out to the generic catalog seam so enumeration/projection/localization
54
+ // reflect the person pack too. Build one PickerDimension per new field.
55
+ const dimByKey = new Map((pack.dimensions ?? []).map((d) => [d.dimension, d]))
56
+ const byField = new Map<string, { field: string; label: string; options: PickerOption[] }>()
57
+ for (const e of pack.entries) {
58
+ const dim = dimByKey.get(e.dimension)
59
+ if (!dim) throw new Error(`person pack "${pack.id}" entry "${e.id}" references undeclared dimension "${e.dimension}"`)
60
+ const opt: PickerOption = {
61
+ id: e.id,
62
+ label: e.label,
63
+ description: e.description,
64
+ category: e.group,
65
+ promptHint: e.promptHint,
66
+ term: resolveTerm(e),
67
+ }
68
+ const existing = byField.get(dim.field)
69
+ if (existing) existing.options.push(opt)
70
+ else byField.set(dim.field, { field: dim.field, label: dim.label, options: [opt] })
71
+ }
72
+ registerCatalogPack({
73
+ id: pack.id,
74
+ catalogId: "person",
75
+ mode: "extend",
76
+ dimensions: [...byField.values()] as PickerDimension[],
77
+ sidecars: pack.sidecars,
78
+ exemptSidecarLocales: pack.exemptSidecarLocales,
79
+ })
80
+ syncPersonPackFields()
81
+ }
82
+
83
+ export function resetPersonPacks(): void {
84
+ personPacks = []
85
+ version++
86
+ syncPersonPackFields()
87
+ }
88
+ export function personPacksVersion(): number {
89
+ return version
90
+ }
91
+
92
+ /** Reverse of the registered dimension→field map, for reconstructing entries
93
+ * from a projected (field-keyed) catalog pack back into Person shape. */
94
+ function dimensionByField(): Readonly<Record<string, string>> {
95
+ const out: Record<string, string> = {}
96
+ const map = getRegisteredPersonFieldByDimension()
97
+ for (const dim of Object.keys(map)) out[map[dim]] = dim
98
+ return out
99
+ }
100
+
101
+ /** Best-effort reconstruction of Person entries from a pack's projected
102
+ * (PickerDimension) list. `shortLabel`/swatches are absent in the projection
103
+ * and fall back at render time; the safety-critical id-set is exact. */
104
+ function personEntriesFromDims(
105
+ dims: ReadonlyArray<{
106
+ field: string
107
+ options: ReadonlyArray<{
108
+ id: string
109
+ label: string
110
+ description?: string
111
+ category?: string
112
+ promptHint: string
113
+ }>
114
+ }>,
115
+ ): RegisteredPersonEntry[] {
116
+ const byField = dimensionByField()
117
+ const out: RegisteredPersonEntry[] = []
118
+ for (const d of dims) {
119
+ const dimension = byField[d.field] ?? d.field
120
+ for (const o of d.options) {
121
+ out.push({
122
+ id: o.id,
123
+ label: o.label,
124
+ description: o.description ?? "",
125
+ group: o.category,
126
+ promptHint: o.promptHint,
127
+ dimension,
128
+ } as RegisteredPersonEntry)
129
+ }
130
+ }
131
+ return out
132
+ }
133
+
134
+ /**
135
+ * The registered/composed person taxonomy: base `PEOPLE` folded with every
136
+ * `catalogId:"person"` CatalogPack in registration order (the SAME registry
137
+ * `composePickerCatalogs` folds for `/v1/catalogs` + MCP). `extend` appends the
138
+ * pack's full Person entries, `deny` removes `denyIds`, `replace` swaps to a
139
+ * reconstruction of the vendored catalog. This is the single funnel the
140
+ * picker-ui grids read, so a deploy's deny/replace curation hides base entries
141
+ * in the picker exactly as it hides them in the catalogs projection.
142
+ */
143
+ export function getRegisteredPeople(): readonly RegisteredPersonEntry[] {
144
+ const personCatalogPacks = getRegisteredCatalogPacks().filter((p) => p.catalogId === "person")
145
+ // Mainline identity on the empty path, matching every sibling getter below
146
+ // (`extra.length === 0 ? BASE : [...]`): with no person packs registered this
147
+ // returns the base PEOPLE reference ITSELF, not a copy — the overlay
148
+ // boot-smoke pins "inert boot" on exactly that identity, and an
149
+ // unconditional copy here is what broke the combined tree after two
150
+ // separately-green merges.
151
+ if (personCatalogPacks.length === 0) return PEOPLE
152
+ let people: RegisteredPersonEntry[] = [...PEOPLE]
153
+ const packsById = new Map(personPacks.map((p) => [p.id, p]))
154
+ for (const pack of personCatalogPacks) {
155
+ if (pack.mode === "extend") {
156
+ // Prefer full Person entries from the source person-pack; fall back to
157
+ // reconstructing from the projected dimensions for a direct extend pack.
158
+ const src = packsById.get(pack.id)
159
+ people = src
160
+ ? [...people, ...src.entries]
161
+ : [...people, ...personEntriesFromDims(pack.dimensions ?? [])]
162
+ } else if (pack.mode === "deny") {
163
+ const deny = new Set(pack.denyIds ?? [])
164
+ people = people.filter((e) => !deny.has(e.id))
165
+ } else if (pack.mode === "replace" && pack.catalog) {
166
+ people = personEntriesFromDims(pack.catalog.dimensions ?? [])
167
+ }
168
+ }
169
+ return people
170
+ }
171
+ export function getRegisteredPersonDimensionOrder(): readonly string[] {
172
+ const extra = personPacks.flatMap((p) => (p.dimensions ?? []).map((d) => d.dimension))
173
+ return extra.length === 0 ? PERSON_DIMENSION_ORDER : [...PERSON_DIMENSION_ORDER, ...extra]
174
+ }
175
+ export function getRegisteredPersonFieldByDimension(): Readonly<Record<string, string>> {
176
+ const extra = personPacks.flatMap((p) => (p.dimensions ?? []).map((d) => [d.dimension, d.field] as const))
177
+ return extra.length === 0 ? PERSON_FIELD_BY_DIMENSION : { ...PERSON_FIELD_BY_DIMENSION, ...Object.fromEntries(extra) }
178
+ }
179
+ export function getRegisteredPersonDimensionLabels(): Readonly<Record<string, string>> {
180
+ const extra = personPacks.flatMap((p) => (p.dimensions ?? []).map((d) => [d.dimension, d.label] as const))
181
+ return extra.length === 0 ? PERSON_DIMENSION_LABELS : { ...PERSON_DIMENSION_LABELS, ...Object.fromEntries(extra) }
182
+ }