@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
@@ -14,6 +14,8 @@
14
14
 
15
15
  import { pickIds } from "@nodaro/shared"
16
16
 
17
+ import { resolveTerm, type PickerHintMode } from "./term.js"
18
+
17
19
  export type MusicGenreCategory =
18
20
  | "hip-hop-rnb"
19
21
  | "electronic"
@@ -47,6 +49,16 @@ export interface MusicSubgenre {
47
49
  readonly id: string
48
50
  readonly label: string
49
51
  readonly promptHint: string
52
+ /**
53
+ * Compact professional form injected in COMPACT hint mode (see `term.ts`).
54
+ * Authored where the lowercased label is not the genre name a producer would
55
+ * write — the picker drops the parent genre from the label to keep the tile
56
+ * short ("Conscious" → "conscious hip hop", "Beats" → "lo-fi beats"), the
57
+ * label is a UI compound the derivation mangles ("Drum & Bass",
58
+ * "Trap (EDM)"), or the bare word means something else entirely outside
59
+ * music ("Dub", "Swing", "Romantic"). Everywhere else the label IS the term.
60
+ */
61
+ readonly term?: string
50
62
  }
51
63
 
52
64
  export interface MusicGenre {
@@ -54,6 +66,12 @@ export interface MusicGenre {
54
66
  readonly label: string
55
67
  readonly description: string
56
68
  readonly promptHint: string
69
+ /**
70
+ * Compact professional form (see `term.ts`), authored where the lowercased
71
+ * label does not read as a music genre on its own — "World" → "world music",
72
+ * "R&B" → "rhythm and blues". Everywhere else the label IS the term.
73
+ */
74
+ readonly term?: string
57
75
  readonly category: MusicGenreCategory
58
76
  readonly subgenres: ReadonlyArray<MusicSubgenre>
59
77
  }
@@ -63,6 +81,12 @@ export interface MusicEra {
63
81
  readonly label: string
64
82
  readonly description: string
65
83
  readonly promptHint: string
84
+ /**
85
+ * Compact professional form (see `term.ts`). The decade labels already ARE
86
+ * the term; authored only for the named eras whose label is not what a
87
+ * producer writes ("Futurist" → "futuristic").
88
+ */
89
+ readonly term?: string
66
90
  }
67
91
 
68
92
  // Sourced from frontend/src/lib/suno-tags.ts SUNO_TAGS Genre + Subgenre buckets.
@@ -81,8 +105,8 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
81
105
  { id: "drill", label: "Drill", promptHint: "drill" },
82
106
  { id: "uk-drill", label: "UK Drill", promptHint: "UK drill" },
83
107
  { id: "cloud-rap", label: "Cloud Rap", promptHint: "cloud rap" },
84
- { id: "conscious-rap", label: "Conscious", promptHint: "conscious hip hop" },
85
- { id: "gangsta-rap", label: "Gangsta", promptHint: "gangsta rap" },
108
+ { id: "conscious-rap", label: "Conscious", promptHint: "conscious hip hop", term: "conscious hip hop" },
109
+ { id: "gangsta-rap", label: "Gangsta", promptHint: "gangsta rap", term: "gangsta rap" },
86
110
  { id: "jazz-rap", label: "Jazz Rap", promptHint: "jazz rap" },
87
111
  { id: "alt-hip-hop", label: "Alt Hip Hop", promptHint: "alternative hip hop" },
88
112
  { id: "phonk", label: "Phonk", promptHint: "phonk" },
@@ -90,7 +114,7 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
90
114
  { id: "uk-hip-hop", label: "UK Hip Hop", promptHint: "UK hip hop" },
91
115
  { id: "grime", label: "Grime", promptHint: "grime" },
92
116
  { id: "pluggnb", label: "PluggnB", promptHint: "pluggnb" },
93
- { id: "rage", label: "Rage", promptHint: "rage hip hop" },
117
+ { id: "rage", label: "Rage", promptHint: "rage hip hop", term: "rage hip hop" },
94
118
  { id: "jersey-club", label: "Jersey Club", promptHint: "Jersey club" },
95
119
  { id: "brazilian-funk", label: "Brazilian Funk", promptHint: "Brazilian funk" },
96
120
  { id: "moombahton", label: "Moombahton", promptHint: "moombahton" },
@@ -102,12 +126,13 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
102
126
  label: "R&B",
103
127
  description: "Smooth vocals, soul-influenced",
104
128
  promptHint: "R&B",
129
+ term: "rhythm and blues",
105
130
  category: "hip-hop-rnb",
106
131
  subgenres: [
107
132
  { id: "neo-soul", label: "Neo-Soul", promptHint: "neo-soul" },
108
- { id: "contemporary-rnb", label: "Contemporary R&B", promptHint: "contemporary R&B" },
109
- { id: "alt-rnb", label: "Alt R&B", promptHint: "alternative R&B" },
110
- { id: "neo-rnb", label: "Neo R&B", promptHint: "modern R&B" },
133
+ { id: "contemporary-rnb", label: "Contemporary R&B", promptHint: "contemporary R&B", term: "contemporary rhythm and blues" },
134
+ { id: "alt-rnb", label: "Alt R&B", promptHint: "alternative R&B", term: "alternative rhythm and blues" },
135
+ { id: "neo-rnb", label: "Neo R&B", promptHint: "modern R&B", term: "modern rhythm and blues" },
111
136
  { id: "quiet-storm", label: "Quiet Storm", promptHint: "quiet storm" },
112
137
  { id: "new-jack-swing", label: "New Jack Swing", promptHint: "new jack swing" },
113
138
  { id: "future-soul", label: "Future Soul", promptHint: "future soul" },
@@ -146,8 +171,8 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
146
171
  promptHint: "lo-fi",
147
172
  category: "hip-hop-rnb",
148
173
  subgenres: [
149
- { id: "lofi-beats", label: "Beats", promptHint: "lo-fi beats" },
150
- { id: "lofi-jazz", label: "Jazz", promptHint: "lo-fi jazz" },
174
+ { id: "lofi-beats", label: "Beats", promptHint: "lo-fi beats", term: "lo-fi beats" },
175
+ { id: "lofi-jazz", label: "Jazz", promptHint: "lo-fi jazz", term: "lo-fi jazz" },
151
176
  { id: "chillhop", label: "Chillhop", promptHint: "chillhop" },
152
177
  ],
153
178
  },
@@ -168,18 +193,18 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
168
193
  { id: "techno", label: "Techno", promptHint: "techno" },
169
194
  { id: "minimal-techno", label: "Minimal Techno", promptHint: "minimal techno" },
170
195
  { id: "melodic-techno", label: "Melodic Techno", promptHint: "melodic techno" },
171
- { id: "drum-and-bass", label: "Drum & Bass", promptHint: "drum and bass" },
196
+ { id: "drum-and-bass", label: "Drum & Bass", promptHint: "drum and bass", term: "drum and bass" },
172
197
  { id: "jungle", label: "Jungle", promptHint: "jungle" },
173
198
  { id: "dubstep", label: "Dubstep", promptHint: "dubstep" },
174
199
  { id: "future-bass", label: "Future Bass", promptHint: "future bass" },
175
- { id: "trap-edm", label: "Trap (EDM)", promptHint: "trap EDM" },
200
+ { id: "trap-edm", label: "Trap (EDM)", promptHint: "trap EDM", term: "trap edm" },
176
201
  { id: "synthwave", label: "Synthwave", promptHint: "synthwave" },
177
- { id: "outrun", label: "Outrun", promptHint: "outrun synthwave" },
202
+ { id: "outrun", label: "Outrun", promptHint: "outrun synthwave", term: "outrun synthwave" },
178
203
  { id: "vaporwave", label: "Vaporwave", promptHint: "vaporwave" },
179
204
  { id: "trance", label: "Trance", promptHint: "trance" },
180
205
  { id: "psytrance", label: "Psytrance", promptHint: "psytrance" },
181
- { id: "ambient", label: "Ambient", promptHint: "ambient electronic" },
182
- { id: "idm", label: "IDM", promptHint: "intelligent dance music" },
206
+ { id: "ambient", label: "Ambient", promptHint: "ambient electronic", term: "ambient electronic" },
207
+ { id: "idm", label: "IDM", promptHint: "intelligent dance music", term: "intelligent dance music" },
183
208
  { id: "breakbeat", label: "Breakbeat", promptHint: "breakbeat" },
184
209
  { id: "garage-uk", label: "UK Garage", promptHint: "UK garage" },
185
210
  { id: "hardstyle", label: "Hardstyle", promptHint: "hardstyle" },
@@ -194,9 +219,9 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
194
219
  promptHint: "industrial",
195
220
  category: "electronic",
196
221
  subgenres: [
197
- { id: "ebm", label: "EBM", promptHint: "electronic body music" },
222
+ { id: "ebm", label: "EBM", promptHint: "electronic body music", term: "electronic body music" },
198
223
  { id: "industrial-metal", label: "Industrial Metal", promptHint: "industrial metal" },
199
- { id: "noise", label: "Noise", promptHint: "noise industrial" },
224
+ { id: "noise", label: "Noise", promptHint: "noise industrial", term: "industrial noise" },
200
225
  ],
201
226
  },
202
227
  {
@@ -208,8 +233,8 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
208
233
  subgenres: [
209
234
  { id: "dark-ambient", label: "Dark Ambient", promptHint: "dark ambient" },
210
235
  { id: "drone", label: "Drone", promptHint: "drone" },
211
- { id: "new-age", label: "New Age", promptHint: "new age ambient" },
212
- { id: "space-music", label: "Space", promptHint: "space ambient" },
236
+ { id: "new-age", label: "New Age", promptHint: "new age ambient", term: "new age ambient" },
237
+ { id: "space-music", label: "Space", promptHint: "space ambient", term: "space ambient" },
213
238
  ],
214
239
  },
215
240
  {
@@ -220,8 +245,8 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
220
245
  category: "electronic",
221
246
  subgenres: [
222
247
  { id: "musique-concrete", label: "Musique Concrète", promptHint: "musique concrète" },
223
- { id: "noise-music", label: "Noise", promptHint: "noise music" },
224
- { id: "free-improv", label: "Free Improv", promptHint: "free improvisation" },
248
+ { id: "noise-music", label: "Noise", promptHint: "noise music", term: "noise music" },
249
+ { id: "free-improv", label: "Free Improv", promptHint: "free improvisation", term: "free improvisation" },
225
250
  { id: "sound-art", label: "Sound Art", promptHint: "sound art" },
226
251
  ],
227
252
  },
@@ -265,8 +290,8 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
265
290
  { id: "k-pop", label: "K-Pop", promptHint: "k-pop" },
266
291
  { id: "j-pop", label: "J-Pop", promptHint: "j-pop" },
267
292
  { id: "art-pop", label: "Art Pop", promptHint: "art pop" },
268
- { id: "bubblegum-pop", label: "Bubblegum", promptHint: "bubblegum pop" },
269
- { id: "chamber-pop", label: "Chamber", promptHint: "chamber pop" },
293
+ { id: "bubblegum-pop", label: "Bubblegum", promptHint: "bubblegum pop", term: "bubblegum pop" },
294
+ { id: "chamber-pop", label: "Chamber", promptHint: "chamber pop", term: "chamber pop" },
270
295
  { id: "electro-pop", label: "Electro Pop", promptHint: "electro pop" },
271
296
  { id: "hyperpop", label: "Hyperpop", promptHint: "hyperpop" },
272
297
  { id: "power-pop", label: "Power Pop", promptHint: "power pop" },
@@ -278,9 +303,10 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
278
303
  label: "Anime / J-Rock",
279
304
  description: "Japanese animation soundtrack and J-Rock",
280
305
  promptHint: "anime",
306
+ term: "anime music",
281
307
  category: "pop",
282
308
  subgenres: [
283
- { id: "anime-opening", label: "Opening", promptHint: "anime opening" },
309
+ { id: "anime-opening", label: "Opening", promptHint: "anime opening", term: "anime opening" },
284
310
  { id: "j-rock", label: "J-Rock", promptHint: "J-rock" },
285
311
  { id: "city-pop", label: "City Pop", promptHint: "city pop" },
286
312
  ],
@@ -298,15 +324,15 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
298
324
  { id: "hard-rock", label: "Hard Rock", promptHint: "hard rock" },
299
325
  { id: "indie-rock", label: "Indie Rock", promptHint: "indie rock" },
300
326
  { id: "punk-rock", label: "Punk Rock", promptHint: "punk rock" },
301
- { id: "alt-rock", label: "Alternative", promptHint: "alternative rock" },
302
- { id: "psychedelic-rock", label: "Psychedelic", promptHint: "psychedelic rock" },
303
- { id: "garage-rock", label: "Garage", promptHint: "garage rock" },
304
- { id: "surf-rock", label: "Surf", promptHint: "surf rock" },
305
- { id: "prog-rock", label: "Progressive", promptHint: "progressive rock" },
327
+ { id: "alt-rock", label: "Alternative", promptHint: "alternative rock", term: "alternative rock" },
328
+ { id: "psychedelic-rock", label: "Psychedelic", promptHint: "psychedelic rock", term: "psychedelic rock" },
329
+ { id: "garage-rock", label: "Garage", promptHint: "garage rock", term: "garage rock" },
330
+ { id: "surf-rock", label: "Surf", promptHint: "surf rock", term: "surf rock" },
331
+ { id: "prog-rock", label: "Progressive", promptHint: "progressive rock", term: "progressive rock" },
306
332
  { id: "post-rock", label: "Post-Rock", promptHint: "post-rock" },
307
333
  { id: "shoegaze", label: "Shoegaze", promptHint: "shoegaze" },
308
334
  { id: "math-rock", label: "Math Rock", promptHint: "math rock" },
309
- { id: "stoner-rock", label: "Stoner", promptHint: "stoner rock" },
335
+ { id: "stoner-rock", label: "Stoner", promptHint: "stoner rock", term: "stoner rock" },
310
336
  { id: "emo", label: "Emo", promptHint: "emo" },
311
337
  ],
312
338
  },
@@ -317,11 +343,11 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
317
343
  promptHint: "punk",
318
344
  category: "rock-metal",
319
345
  subgenres: [
320
- { id: "hardcore-punk", label: "Hardcore", promptHint: "hardcore punk" },
346
+ { id: "hardcore-punk", label: "Hardcore", promptHint: "hardcore punk", term: "hardcore punk" },
321
347
  { id: "post-punk", label: "Post-Punk", promptHint: "post-punk" },
322
348
  { id: "pop-punk", label: "Pop Punk", promptHint: "pop punk" },
323
349
  { id: "skate-punk", label: "Skate Punk", promptHint: "skate punk" },
324
- { id: "anarcho-punk", label: "Anarcho", promptHint: "anarcho-punk" },
350
+ { id: "anarcho-punk", label: "Anarcho", promptHint: "anarcho-punk", term: "anarcho-punk" },
325
351
  ],
326
352
  },
327
353
  {
@@ -334,15 +360,15 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
334
360
  { id: "heavy-metal", label: "Heavy Metal", promptHint: "heavy metal" },
335
361
  { id: "death-metal", label: "Death Metal", promptHint: "death metal" },
336
362
  { id: "black-metal", label: "Black Metal", promptHint: "black metal" },
337
- { id: "thrash-metal", label: "Thrash", promptHint: "thrash metal" },
338
- { id: "doom-metal", label: "Doom", promptHint: "doom metal" },
339
- { id: "power-metal", label: "Power", promptHint: "power metal" },
340
- { id: "prog-metal", label: "Progressive", promptHint: "progressive metal" },
363
+ { id: "thrash-metal", label: "Thrash", promptHint: "thrash metal", term: "thrash metal" },
364
+ { id: "doom-metal", label: "Doom", promptHint: "doom metal", term: "doom metal" },
365
+ { id: "power-metal", label: "Power", promptHint: "power metal", term: "power metal" },
366
+ { id: "prog-metal", label: "Progressive", promptHint: "progressive metal", term: "progressive metal" },
341
367
  { id: "metalcore", label: "Metalcore", promptHint: "metalcore" },
342
368
  { id: "deathcore", label: "Deathcore", promptHint: "deathcore" },
343
369
  { id: "nu-metal", label: "Nu-Metal", promptHint: "nu-metal" },
344
- { id: "sludge-metal", label: "Sludge", promptHint: "sludge metal" },
345
- { id: "symphonic-metal", label: "Symphonic", promptHint: "symphonic metal" },
370
+ { id: "sludge-metal", label: "Sludge", promptHint: "sludge metal", term: "sludge metal" },
371
+ { id: "symphonic-metal", label: "Symphonic", promptHint: "symphonic metal", term: "symphonic metal" },
346
372
  ],
347
373
  },
348
374
 
@@ -356,10 +382,10 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
356
382
  subgenres: [
357
383
  { id: "bebop", label: "Bebop", promptHint: "bebop" },
358
384
  { id: "smooth-jazz", label: "Smooth Jazz", promptHint: "smooth jazz" },
359
- { id: "fusion", label: "Fusion", promptHint: "jazz fusion" },
385
+ { id: "fusion", label: "Fusion", promptHint: "jazz fusion", term: "jazz fusion" },
360
386
  { id: "cool-jazz", label: "Cool Jazz", promptHint: "cool jazz" },
361
387
  { id: "free-jazz", label: "Free Jazz", promptHint: "free jazz" },
362
- { id: "swing", label: "Swing", promptHint: "swing jazz" },
388
+ { id: "swing", label: "Swing", promptHint: "swing jazz", term: "swing jazz" },
363
389
  { id: "big-band", label: "Big Band", promptHint: "big band jazz" },
364
390
  { id: "dixieland", label: "Dixieland", promptHint: "dixieland jazz" },
365
391
  { id: "latin-jazz", label: "Latin Jazz", promptHint: "latin jazz" },
@@ -405,8 +431,8 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
405
431
  category: "acoustic",
406
432
  subgenres: [
407
433
  { id: "indie-folk", label: "Indie Folk", promptHint: "indie folk" },
408
- { id: "traditional-folk", label: "Traditional", promptHint: "traditional folk" },
409
- { id: "celtic-folk", label: "Celtic", promptHint: "Celtic folk" },
434
+ { id: "traditional-folk", label: "Traditional", promptHint: "traditional folk", term: "traditional folk" },
435
+ { id: "celtic-folk", label: "Celtic", promptHint: "Celtic folk", term: "celtic folk" },
410
436
  { id: "freak-folk", label: "Freak Folk", promptHint: "freak folk" },
411
437
  { id: "folk-rock", label: "Folk Rock", promptHint: "folk rock" },
412
438
  { id: "anti-folk", label: "Anti-Folk", promptHint: "anti-folk" },
@@ -419,9 +445,9 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
419
445
  promptHint: "gospel",
420
446
  category: "acoustic",
421
447
  subgenres: [
422
- { id: "traditional-gospel", label: "Traditional", promptHint: "traditional gospel" },
423
- { id: "contemporary-gospel",label: "Contemporary", promptHint: "contemporary gospel" },
424
- { id: "urban-gospel", label: "Urban", promptHint: "urban gospel" },
448
+ { id: "traditional-gospel", label: "Traditional", promptHint: "traditional gospel", term: "traditional gospel" },
449
+ { id: "contemporary-gospel",label: "Contemporary", promptHint: "contemporary gospel", term: "contemporary gospel" },
450
+ { id: "urban-gospel", label: "Urban", promptHint: "urban gospel", term: "urban gospel" },
425
451
  ],
426
452
  },
427
453
 
@@ -433,8 +459,8 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
433
459
  promptHint: "reggae",
434
460
  category: "global",
435
461
  subgenres: [
436
- { id: "roots-reggae", label: "Roots", promptHint: "roots reggae" },
437
- { id: "dub", label: "Dub", promptHint: "dub reggae" },
462
+ { id: "roots-reggae", label: "Roots", promptHint: "roots reggae", term: "roots reggae" },
463
+ { id: "dub", label: "Dub", promptHint: "dub reggae", term: "dub reggae" },
438
464
  { id: "dancehall", label: "Dancehall", promptHint: "dancehall" },
439
465
  { id: "ska", label: "Ska", promptHint: "ska" },
440
466
  { id: "rocksteady", label: "Rocksteady", promptHint: "rocksteady" },
@@ -466,6 +492,7 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
466
492
  label: "World",
467
493
  description: "Non-Western musical traditions",
468
494
  promptHint: "world music",
495
+ term: "world music",
469
496
  category: "global",
470
497
  subgenres: [
471
498
  { id: "afrobeat", label: "Afrobeat", promptHint: "afrobeat" },
@@ -493,11 +520,11 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
493
520
  category: "cinematic",
494
521
  subgenres: [
495
522
  { id: "baroque", label: "Baroque", promptHint: "baroque classical" },
496
- { id: "romantic", label: "Romantic", promptHint: "romantic-era classical" },
523
+ { id: "romantic", label: "Romantic", promptHint: "romantic-era classical", term: "romantic-era classical" },
497
524
  { id: "modern-classical", label: "Modern Classical", promptHint: "modern classical" },
498
- { id: "minimalist-classical",label: "Minimalist", promptHint: "minimalist classical" },
525
+ { id: "minimalist-classical",label: "Minimalist", promptHint: "minimalist classical", term: "minimalist classical" },
499
526
  { id: "choral", label: "Choral", promptHint: "choral classical" },
500
- { id: "chamber-music", label: "Chamber", promptHint: "chamber music" },
527
+ { id: "chamber-music", label: "Chamber", promptHint: "chamber music", term: "chamber music" },
501
528
  { id: "opera", label: "Opera", promptHint: "operatic" },
502
529
  { id: "early-music", label: "Early Music", promptHint: "early music" },
503
530
  ],
@@ -511,14 +538,14 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
511
538
  subgenres: [
512
539
  { id: "epic-orchestral", label: "Epic Orchestral", promptHint: "epic orchestral cinematic" },
513
540
  { id: "minimalist-score", label: "Minimalist Score", promptHint: "minimalist cinematic score" },
514
- { id: "trailer-music", label: "Trailer", promptHint: "trailer cinematic music" },
515
- { id: "score-action", label: "Action", promptHint: "action film score" },
516
- { id: "score-horror", label: "Horror", promptHint: "horror film score" },
517
- { id: "score-romance", label: "Romance", promptHint: "romantic film score" },
518
- { id: "score-sci-fi", label: "Sci-Fi", promptHint: "sci-fi film score" },
519
- { id: "score-fantasy", label: "Fantasy", promptHint: "fantasy film score" },
520
- { id: "score-noir", label: "Noir", promptHint: "noir cinematic" },
521
- { id: "score-documentary", label: "Documentary", promptHint: "documentary score" },
541
+ { id: "trailer-music", label: "Trailer", promptHint: "trailer cinematic music", term: "trailer music" },
542
+ { id: "score-action", label: "Action", promptHint: "action film score", term: "action film score" },
543
+ { id: "score-horror", label: "Horror", promptHint: "horror film score", term: "horror film score" },
544
+ { id: "score-romance", label: "Romance", promptHint: "romantic film score", term: "romantic film score" },
545
+ { id: "score-sci-fi", label: "Sci-Fi", promptHint: "sci-fi film score", term: "sci-fi film score" },
546
+ { id: "score-fantasy", label: "Fantasy", promptHint: "fantasy film score", term: "fantasy film score" },
547
+ { id: "score-noir", label: "Noir", promptHint: "noir cinematic", term: "noir film score" },
548
+ { id: "score-documentary", label: "Documentary", promptHint: "documentary score", term: "documentary score" },
522
549
  ],
523
550
  },
524
551
  {
@@ -526,13 +553,14 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
526
553
  label: "Video Game",
527
554
  description: "Game-score-style — chiptune, orchestral, electronic",
528
555
  promptHint: "video game music",
556
+ term: "video game music",
529
557
  category: "cinematic",
530
558
  subgenres: [
531
559
  { id: "chiptune", label: "Chiptune", promptHint: "chiptune" },
532
560
  { id: "8bit", label: "8-bit", promptHint: "8-bit" },
533
561
  { id: "16bit", label: "16-bit", promptHint: "16-bit" },
534
- { id: "vgm-orchestral", label: "Orchestral VGM", promptHint: "orchestral video game" },
535
- { id: "vgm-synth", label: "Synth VGM", promptHint: "synth video game" },
562
+ { id: "vgm-orchestral", label: "Orchestral VGM", promptHint: "orchestral video game", term: "orchestral video game score" },
563
+ { id: "vgm-synth", label: "Synth VGM", promptHint: "synth video game", term: "synth video game score" },
536
564
  ],
537
565
  },
538
566
  {
@@ -543,7 +571,7 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
543
571
  category: "cinematic",
544
572
  subgenres: [
545
573
  { id: "christmas", label: "Christmas", promptHint: "Christmas" },
546
- { id: "winter", label: "Winter", promptHint: "winter holiday" },
574
+ { id: "winter", label: "Winter", promptHint: "winter holiday", term: "winter holiday" },
547
575
  { id: "festive", label: "Festive", promptHint: "festive" },
548
576
  ],
549
577
  },
@@ -552,6 +580,7 @@ export const MUSIC_GENRES: ReadonlyArray<MusicGenre> = [
552
580
  label: "Children's",
553
581
  description: "Kids music — sing-alongs, lullabies, educational",
554
582
  promptHint: "children's music",
583
+ term: "children's music",
555
584
  category: "cinematic",
556
585
  subgenres: [
557
586
  { id: "lullaby", label: "Lullaby", promptHint: "lullaby" },
@@ -573,7 +602,7 @@ export const MUSIC_ERAS: ReadonlyArray<MusicEra> = [
573
602
  { id: "2000s", label: "2000s", description: "Pop-punk, R&B revival", promptHint: "early 2000s" },
574
603
  { id: "2010s", label: "2010s", description: "EDM mainstream, streaming era", promptHint: "2010s" },
575
604
  { id: "modern", label: "Modern", description: "2020s+ contemporary", promptHint: "modern" },
576
- { id: "futurist", label: "Futurist", description: "Sci-fi, otherworldly", promptHint: "futuristic" },
605
+ { id: "futurist", label: "Futurist", description: "Sci-fi, otherworldly", promptHint: "futuristic", term: "futuristic" },
577
606
  ] as const
578
607
 
579
608
  const GENRE_BY_ID = new Map(MUSIC_GENRES.map((g) => [g.id, g]))
@@ -603,6 +632,33 @@ export function getMusicEra(id: string | undefined): MusicEra | undefined {
603
632
  return ERA_BY_ID.get(id)
604
633
  }
605
634
 
635
+ /**
636
+ * Compact counterpart of a genre's `promptHint`: the short genre name a
637
+ * producer would write ("rhythm and blues", "world music"). Same lookup and
638
+ * same empty-string-on-miss behavior as `getMusicGenre`, so hint mode and
639
+ * compact mode can never disagree about which genre they describe.
640
+ */
641
+ export function getMusicGenreTerm(id: string | undefined | null): string {
642
+ return resolveTerm(getMusicGenre(id ?? undefined))
643
+ }
644
+
645
+ /**
646
+ * Compact counterpart for a subgenre. Keeps `getMusicSubgenre`'s two-argument
647
+ * lookup — a subgenre id is unique only within its parent genre, so a
648
+ * single-id term getter could not resolve one.
649
+ */
650
+ export function getMusicSubgenreTerm(
651
+ genreId: string | undefined | null,
652
+ subgenreId: string | undefined | null,
653
+ ): string {
654
+ return resolveTerm(getMusicSubgenre(genreId ?? undefined, subgenreId ?? undefined))
655
+ }
656
+
657
+ /** Compact counterpart of an era's `promptHint` — the short period name. */
658
+ export function getMusicEraTerm(id: string | undefined | null): string {
659
+ return resolveTerm(getMusicEra(id ?? undefined))
660
+ }
661
+
606
662
  /**
607
663
  * Compose hints from MusicGenreData: optional preText, structured
608
664
  * [era] [subgenre|genre] (or " / "-joined genres for multi), optional
@@ -613,13 +669,17 @@ export function getMusicEra(id: string | undefined): MusicEra | undefined {
613
669
  * — subgenre is ignored in multi-mode (subgenre is meaningful only against
614
670
  * a single chosen genre).
615
671
  */
616
- export function buildMusicGenreHints(data: {
617
- readonly preText?: string
618
- readonly postText?: string
619
- readonly genre?: string | ReadonlyArray<string>
620
- readonly subgenre?: string
621
- readonly era?: string
622
- }): string {
672
+ export function buildMusicGenreHints(
673
+ data: {
674
+ readonly preText?: string
675
+ readonly postText?: string
676
+ readonly genre?: string | ReadonlyArray<string>
677
+ readonly subgenre?: string
678
+ readonly era?: string
679
+ },
680
+ mode: PickerHintMode = "full",
681
+ ): string {
682
+ if (mode === "compact") return buildMusicGenreTerms(data)
623
683
  const fragments: string[] = []
624
684
 
625
685
  const pre = typeof data.preText === "string" ? data.preText.trim() : ""
@@ -652,6 +712,50 @@ export function buildMusicGenreHints(data: {
652
712
  return fragments.join(", ")
653
713
  }
654
714
 
715
+ /**
716
+ * Compact-mode mirror of `buildMusicGenreHints`: the same preText / [era]
717
+ * [subgenre|genre] / postText structure, built from each entry's short `term`
718
+ * instead of its `promptHint` ("1980s synth pop"). Multi-genre still joins
719
+ * with " / " and still ignores the subgenre, and preText / postText — user
720
+ * free text, not catalog copy — pass through untouched.
721
+ */
722
+ export function buildMusicGenreTerms(data: {
723
+ readonly preText?: string
724
+ readonly postText?: string
725
+ readonly genre?: string | ReadonlyArray<string>
726
+ readonly subgenre?: string
727
+ readonly era?: string
728
+ }): string {
729
+ const fragments: string[] = []
730
+
731
+ const pre = typeof data.preText === "string" ? data.preText.trim() : ""
732
+ if (pre) fragments.push(pre)
733
+
734
+ const parts: string[] = []
735
+ const era = getMusicEraTerm(data.era)
736
+ if (era) parts.push(era)
737
+
738
+ const genreIds = pickIds(data.genre)
739
+ if (genreIds.length > 1) {
740
+ const genreTerms = genreIds.map((id) => getMusicGenreTerm(id)).filter((t) => t.length > 0)
741
+ if (genreTerms.length > 0) parts.push(genreTerms.join(" / "))
742
+ } else if (genreIds.length === 1) {
743
+ const sub = getMusicSubgenreTerm(genreIds[0], data.subgenre)
744
+ if (sub) {
745
+ parts.push(sub)
746
+ } else {
747
+ const genre = getMusicGenreTerm(genreIds[0])
748
+ if (genre) parts.push(genre)
749
+ }
750
+ }
751
+ if (parts.length > 0) fragments.push(parts.join(" "))
752
+
753
+ const post = typeof data.postText === "string" ? data.postText.trim() : ""
754
+ if (post) fragments.push(post)
755
+
756
+ return fragments.join(", ")
757
+ }
758
+
655
759
  /** Default data when a music-genre node is dropped on canvas. Empty by design — forces a deliberate pick. */
656
760
  export const MUSIC_GENRE_DEFAULT_DATA: {
657
761
  preText?: string
package/src/music-mood.ts CHANGED
@@ -5,22 +5,34 @@
5
5
 
6
6
  import { pickIds } from "@nodaro/shared"
7
7
 
8
+ import { resolveTerm, type PickerHintMode } from "./term.js"
9
+
8
10
  export interface MusicMoodEntry {
9
11
  readonly id: string
10
12
  readonly label: string
11
13
  readonly description: string
12
14
  readonly promptHint: string
15
+ /**
16
+ * Compact professional term injected in compact hint mode (see `term.ts`).
17
+ *
18
+ * Authored only where the lowercased label is NOT the phrase a music
19
+ * supervisor would write — the bare-degree energies ("Low"/"Moderate"/
20
+ * "High" mean nothing without "energy" attached) and the noun-shaped
21
+ * "Awe". Everywhere else the label already IS the trade descriptor and
22
+ * `resolveTerm` derives it.
23
+ */
24
+ readonly term?: string
13
25
  }
14
26
 
15
27
  export const MUSIC_ENERGIES: ReadonlyArray<MusicMoodEntry> = [
16
- { id: "low", label: "Low", description: "Calm, slow", promptHint: "low-energy" },
28
+ { id: "low", label: "Low", description: "Calm, slow", promptHint: "low-energy", term: "low-energy" },
17
29
  { id: "mellow", label: "Mellow", description: "Relaxed, laid-back", promptHint: "mellow" },
18
30
  { id: "gentle", label: "Gentle", description: "Soft, tender", promptHint: "gentle" },
19
- { id: "moderate", label: "Moderate", description: "Balanced, steady", promptHint: "moderate-energy" },
20
- { id: "building", label: "Building", description: "Slowly intensifying", promptHint: "building energy" },
31
+ { id: "moderate", label: "Moderate", description: "Balanced, steady", promptHint: "moderate-energy", term: "moderate-energy" },
32
+ { id: "building", label: "Building", description: "Slowly intensifying", promptHint: "building energy", term: "building energy" },
21
33
  { id: "upbeat", label: "Upbeat", description: "Lively, optimistic", promptHint: "upbeat" },
22
34
  { id: "driving", label: "Driving", description: "Forward-pushing momentum", promptHint: "driving" },
23
- { id: "high", label: "High", description: "Energetic, intense", promptHint: "high-energy" },
35
+ { id: "high", label: "High", description: "Energetic, intense", promptHint: "high-energy", term: "high-energy" },
24
36
  { id: "explosive", label: "Explosive", description: "Bursting, wild", promptHint: "explosive" },
25
37
  { id: "frenetic", label: "Frenetic", description: "Intense, rapid", promptHint: "frenetic" },
26
38
  { id: "pulsing", label: "Pulsing", description: "Rhythmic, beat-driven", promptHint: "pulsing" },
@@ -57,7 +69,7 @@ export const MUSIC_EMOTIONS: ReadonlyArray<MusicMoodEntry> = [
57
69
  { id: "peaceful", label: "Peaceful", description: "Serene, restful", promptHint: "peaceful" },
58
70
  { id: "contemplative",label: "Contemplative",description: "Reflective, thoughtful", promptHint: "contemplative" },
59
71
  { id: "ethereal", label: "Ethereal", description: "Otherworldly, floating", promptHint: "ethereal" },
60
- { id: "awe", label: "Awe", description: "Wonder, vastness", promptHint: "awe-inspiring" },
72
+ { id: "awe", label: "Awe", description: "Wonder, vastness", promptHint: "awe-inspiring", term: "awe-inspiring" },
61
73
  ] as const
62
74
 
63
75
  export const MUSIC_VIBES: ReadonlyArray<MusicMoodEntry> = [
@@ -99,30 +111,72 @@ export function getMusicVibe(id: string | undefined): MusicMoodEntry | undefined
99
111
  return id ? VIBE_BY_ID.get(id) : undefined
100
112
  }
101
113
 
102
- export function buildMusicMoodHints(data: {
114
+ /**
115
+ * The COMPACT counterparts of the `promptHint` lookups: the short professional
116
+ * term a consumer injects instead of the entry's full fragment ("low-energy",
117
+ * "building energy", "awe-inspiring"). Same lookup as the getters above, same
118
+ * empty-string-on-miss behavior, so hint mode and term mode can never disagree
119
+ * about WHICH entry they are describing.
120
+ */
121
+ export function getMusicEnergyTerm(id: string | undefined | null): string {
122
+ return resolveTerm(id ? getMusicEnergy(id) : undefined)
123
+ }
124
+ export function getMusicEmotionTerm(id: string | undefined | null): string {
125
+ return resolveTerm(id ? getMusicEmotion(id) : undefined)
126
+ }
127
+ export function getMusicVibeTerm(id: string | undefined | null): string {
128
+ return resolveTerm(id ? getMusicVibe(id) : undefined)
129
+ }
130
+
131
+ export interface MusicMoodData {
103
132
  readonly preText?: string
104
133
  readonly postText?: string
105
134
  readonly energy?: string
106
135
  readonly emotion?: string | ReadonlyArray<string>
107
136
  readonly vibe?: string | ReadonlyArray<string>
108
- }): string {
137
+ }
138
+
139
+ /** Resolve one multi-pick field's ids into non-empty fragments, in pick order. */
140
+ function musicMoodFragments(
141
+ value: string | ReadonlyArray<string> | undefined,
142
+ lookup: (id: string) => MusicMoodEntry | undefined,
143
+ fragmentFor: (entry: MusicMoodEntry) => string,
144
+ ): string[] {
145
+ return pickIds(value)
146
+ .map((id) => {
147
+ const entry = lookup(id)
148
+ return entry ? fragmentFor(entry) : ""
149
+ })
150
+ .filter((f) => f.length > 0)
151
+ }
152
+
153
+ /**
154
+ * The one walk over the energy / emotion / vibe fields, parameterized by how a
155
+ * single selected entry turns into a fragment. `buildMusicMoodHints` (verbose)
156
+ * and `buildMusicMoodTerms` (compact) both delegate here, so the two can never
157
+ * disagree about WHICH ids contribute or in what order — only about how each
158
+ * one is phrased. Free-text pre/post is user wording and passes through both.
159
+ */
160
+ function composeMusicMood(
161
+ data: MusicMoodData,
162
+ fragmentFor: (entry: MusicMoodEntry) => string,
163
+ ): string {
109
164
  const fragments: string[] = []
110
165
  const pre = typeof data.preText === "string" ? data.preText.trim() : ""
111
166
  if (pre) fragments.push(pre)
112
167
 
113
168
  const parts: string[] = []
114
- const e = getMusicEnergy(data.energy)
115
- if (e) parts.push(e.promptHint)
169
+ const energy = getMusicEnergy(data.energy)
170
+ if (energy) {
171
+ const energyFragment = fragmentFor(energy)
172
+ if (energyFragment) parts.push(energyFragment)
173
+ }
116
174
 
117
- const emotionHints = pickIds(data.emotion)
118
- .map((id) => getMusicEmotion(id)?.promptHint)
119
- .filter((h): h is string => !!h)
120
- if (emotionHints.length > 0) parts.push(emotionHints.join(", "))
175
+ const emotionFragments = musicMoodFragments(data.emotion, getMusicEmotion, fragmentFor)
176
+ if (emotionFragments.length > 0) parts.push(emotionFragments.join(", "))
121
177
 
122
- const vibeHints = pickIds(data.vibe)
123
- .map((id) => getMusicVibe(id)?.promptHint)
124
- .filter((h): h is string => !!h)
125
- if (vibeHints.length > 0) parts.push(vibeHints.join(", "))
178
+ const vibeFragments = musicMoodFragments(data.vibe, getMusicVibe, fragmentFor)
179
+ if (vibeFragments.length > 0) parts.push(vibeFragments.join(", "))
126
180
 
127
181
  if (parts.length > 0) fragments.push(parts.join(" "))
128
182
 
@@ -132,6 +186,20 @@ export function buildMusicMoodHints(data: {
132
186
  return fragments.join(", ")
133
187
  }
134
188
 
189
+ export function buildMusicMoodHints(data: MusicMoodData, mode: PickerHintMode = "full"): string {
190
+ if (mode === "compact") return buildMusicMoodTerms(data)
191
+ return composeMusicMood(data, (entry) => entry.promptHint)
192
+ }
193
+
194
+ /**
195
+ * The COMPACT counterpart of `buildMusicMoodHints`: the same field walk in the
196
+ * same canonical order, emitting each selection's short professional term
197
+ * instead of its full prompt fragment.
198
+ */
199
+ export function buildMusicMoodTerms(data: MusicMoodData): string {
200
+ return composeMusicMood(data, resolveTerm)
201
+ }
202
+
135
203
  export const MUSIC_MOOD_DEFAULT_DATA: {
136
204
  preText?: string; postText?: string; energy?: string; emotion?: string | ReadonlyArray<string>; vibe?: string | ReadonlyArray<string>
137
205
  } = {}