@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,120 @@
1
+ import { resolveNodeRefs } from "@nodaro/shared"
2
+ import { SOCIAL_POST_NODE_TYPES } from "@nodaro/shared"
3
+
4
+ export interface ResolvePromptArgs {
5
+ override?: string
6
+ typed?: ReadonlyArray<string | undefined>
7
+ wired?: string
8
+ refMap: ReadonlyMap<string, string>
9
+ /** Opt-in (generate-image / generate-video only): APPEND the wired
10
+ * (connected-prompt) value to the TYPED base instead of treating wired as a
11
+ * fallback, so a connected prompt auto-injects alongside the typed prompt. An
12
+ * `override` (list fan-out item) still fully replaces — no wired append.
13
+ * Off/undefined = exact legacy precedence (override > typed > wired) for every
14
+ * other node type. */
15
+ appendWired?: boolean
16
+ }
17
+ const present = (s?: string): s is string => typeof s === "string" && s.trim().length > 0
18
+ const rr = (s: string, m: ReadonlyMap<string, string>) => (m.size > 0 ? resolveNodeRefs(s, m) : s)
19
+
20
+ /** SINGLE SOURCE OF TRUTH for prompt precedence across both DAG engines:
21
+ * override (list fan-out) > first present typed candidate > wired > "".
22
+ * "present" = non-empty after trim. {Label} refs are resolved on the chosen
23
+ * branch via the shared resolveNodeRefs. With `appendWired`, the chosen base
24
+ * AND the wired value are both emitted (joined ". "). */
25
+ export function resolvePrompt({ override, typed = [], wired, refMap, appendWired }: ResolvePromptArgs): string {
26
+ // appendWired: a connected prompt APPENDS to the TYPED base. An `override`
27
+ // (list fan-out item) still fully REPLACES — it never receives a wired append,
28
+ // so per-item fan-out prompts are unchanged.
29
+ if (appendWired && !present(override)) {
30
+ const base = typed.find(present)
31
+ return [base, wired].filter(present).map((s) => rr(s, refMap)).join(". ")
32
+ }
33
+ if (present(override)) return rr(override, refMap)
34
+ for (const t of typed) if (present(t)) return rr(t, refMap)
35
+ if (present(wired)) return rr(wired, refMap)
36
+ return ""
37
+ }
38
+
39
+ /** Compose a final NEGATIVE prompt from a TYPED base + a WIRED (connected
40
+ * negative-handle) value: both are emitted, joined ". " (mirrors `appendWired`
41
+ * for the positive prompt). Pure join — the caller resolves `{label}` refs on
42
+ * the typed value first, and the wired value is already a resolved output that
43
+ * the input-resolver has filtered (referenced / Inject-Negative-off dropped).
44
+ * Generate-image / generate-video only; empty parts are dropped. */
45
+ export function composeNegative(typed?: string, wired?: string): string {
46
+ return [typed, wired].filter(present).join(". ")
47
+ }
48
+
49
+ /** Ordered typed-candidate fields per node type — the precedence source of
50
+ * truth (NOT NODE_MAPPABLE_FIELDS, which is field-mapping eligibility, omits
51
+ * video-retake, and orders llm-chat wrong). */
52
+ export const NODE_PROMPT_CANDIDATE_FIELDS: Readonly<Record<string, readonly string[]>> = {
53
+ "generate-image": ["prompt"],
54
+ // motionPrompt is here so generate-video re-typed to text-to-video keeps a
55
+ // prompt stored in data.motionPrompt (the inline picker's legacy field). Safe
56
+ // for a standalone text-to-video node — it never carries data.motionPrompt.
57
+ "text-to-video": ["prompt", "motionPrompt"],
58
+ "video-to-video": ["prompt"],
59
+ "generate-music": ["prompt"],
60
+ "speech-to-video": ["prompt"],
61
+ "cinematic-avatar": ["prompt"],
62
+ "extend-video": ["prompt"],
63
+ "video-retake": ["prompt"],
64
+ "suno-replace-section": ["prompt"],
65
+ "image-to-video": ["prompt", "motionPrompt"],
66
+ "generate-video": ["prompt", "motionPrompt"],
67
+ "text-to-audio": ["prompt", "text"],
68
+ // Social posts: the orchestrator + frontend executor match per-platform node
69
+ // types (node.type is "instagram-post", "telegram-post", …), NOT a unified
70
+ // "social-publish" type — so every platform needs its own ["caption"] entry.
71
+ // Derived from the shared SOCIAL_POST_NODE_TYPES single source of truth so a
72
+ // new platform can't drift out of the precedence map.
73
+ ...Object.fromEntries(
74
+ [...SOCIAL_POST_NODE_TYPES].map((t) => [t, ["caption"] as readonly string[]]),
75
+ ),
76
+ // Kept for any caller that passes the aggregate type instead of a platform.
77
+ "social-publish": ["caption"],
78
+ }
79
+
80
+ export interface ComputeNodePromptArgs {
81
+ override?: string
82
+ wired?: string
83
+ refMap: ReadonlyMap<string, string>
84
+ /** See ResolvePromptArgs.appendWired — generate-image / generate-video only. */
85
+ appendWired?: boolean
86
+ }
87
+ /** Resolve a single-prompt node's final prompt (typed-primary). Both engines
88
+ * call this so field-selection + precedence are structurally identical. */
89
+ export function computeNodePrompt(
90
+ nodeType: string,
91
+ data: Record<string, unknown>,
92
+ { override, wired, refMap, appendWired }: ComputeNodePromptArgs,
93
+ ): string {
94
+ let typed: ReadonlyArray<string | undefined>
95
+ if (nodeType === "text-to-speech") {
96
+ // data.text is a phantom field on TTS; only directText (gated) is real.
97
+ typed = data.textSource === "direct" ? [data.directText as string | undefined] : []
98
+ } else {
99
+ const fields = NODE_PROMPT_CANDIDATE_FIELDS[nodeType] ?? ["prompt"]
100
+ typed = fields.map((f) => data[f] as string | undefined)
101
+ }
102
+ return resolvePrompt({ override, typed, wired, refMap, appendWired })
103
+ }
104
+
105
+ export interface LlmChatFieldArgs {
106
+ override?: string
107
+ wiredUserInput?: string
108
+ wiredSystemPrompt?: string
109
+ refMap: ReadonlyMap<string, string>
110
+ }
111
+ /** llm-chat resolves TWO independent fields. override applies to userInput only. */
112
+ export function computeLlmChatFields(
113
+ data: Record<string, unknown>,
114
+ { override, wiredUserInput, wiredSystemPrompt, refMap }: LlmChatFieldArgs,
115
+ ): { userInput: string; systemPrompt: string } {
116
+ return {
117
+ userInput: resolvePrompt({ override, typed: [data.userInput as string | undefined], wired: wiredUserInput, refMap }),
118
+ systemPrompt: resolvePrompt({ typed: [data.systemPrompt as string | undefined], wired: wiredSystemPrompt, refMap }),
119
+ }
120
+ }
@@ -0,0 +1,100 @@
1
+ import { SEEDANCE_2_REF_LIMITS } from "@nodaro/shared"
2
+ import { REF_BINDING } from "./video-reference-resolver.js"
3
+
4
+ export type Seedance2Mode = "first-frame" | "first-last-frame" | "reference"
5
+
6
+ export interface Seedance2InputsArgs {
7
+ firstFrameUrl?: string
8
+ lastFrameUrl?: string
9
+ refImageUrls?: readonly string[]
10
+ refVideoUrls?: readonly string[]
11
+ refAudioUrls?: readonly string[]
12
+ }
13
+
14
+ export interface Seedance2InputsResult {
15
+ mode: Seedance2Mode
16
+ firstFrameUrl?: string
17
+ lastFrameUrl?: string
18
+ referenceImageUrls: string[]
19
+ referenceVideoUrls: string[]
20
+ referenceAudioUrls: string[]
21
+ promptSuffix: string
22
+ droppedRefImages: number
23
+ }
24
+
25
+ const clean = (u?: string): string | undefined => {
26
+ const t = u?.trim()
27
+ return t && t.length > 0 ? t : undefined
28
+ }
29
+ const cleanList = (xs?: readonly string[]): string[] =>
30
+ (xs ?? []).map(clean).filter((x): x is string => x !== undefined)
31
+
32
+ /**
33
+ * Single source of truth for how Seedance 2's three mutually-exclusive KIE input
34
+ * modes are selected from connected inputs. Strict first/last-frame mode is used
35
+ * only when nothing but frames is connected; any reference (image, video, OR
36
+ * audio) switches to multimodal Reference mode, where the frames are appended to
37
+ * reference_image_urls (after the user's own images, so their @Image ordinals do
38
+ * not shift) and named in a doctrine-compliant prompt suffix.
39
+ */
40
+ export function resolveSeedance2Inputs(args: Seedance2InputsArgs): Seedance2InputsResult {
41
+ const firstFrameUrl = clean(args.firstFrameUrl)
42
+ const lastFrameUrl = clean(args.lastFrameUrl)
43
+ const refImages = cleanList(args.refImageUrls)
44
+ const refVideos = cleanList(args.refVideoUrls).slice(0, SEEDANCE_2_REF_LIMITS.videos)
45
+ const refAudios = cleanList(args.refAudioUrls).slice(0, SEEDANCE_2_REF_LIMITS.audio)
46
+
47
+ const hasAnyReference = refImages.length > 0 || refVideos.length > 0 || refAudios.length > 0
48
+
49
+ // Strict first/last-frame mode is only expressible when a first frame is
50
+ // present (KIE has no last-frame-only strict mode) and nothing but frames is
51
+ // connected. A lone last frame (no first frame, no references) therefore falls
52
+ // through to Reference mode below, where it becomes the sole reference image
53
+ // with a closing-frame hint. The truly-empty case (no frames at all) stays in
54
+ // the degenerate first-frame branch with no URLs.
55
+ const canUseStrictMode = !hasAnyReference && (Boolean(firstFrameUrl) || !lastFrameUrl)
56
+
57
+ if (canUseStrictMode) {
58
+ if (firstFrameUrl && lastFrameUrl) {
59
+ return { mode: "first-last-frame", firstFrameUrl, lastFrameUrl, referenceImageUrls: [], referenceVideoUrls: [], referenceAudioUrls: [], promptSuffix: "", droppedRefImages: 0 }
60
+ }
61
+ return { mode: "first-frame", firstFrameUrl, lastFrameUrl: undefined, referenceImageUrls: [], referenceVideoUrls: [], referenceAudioUrls: [], promptSuffix: "", droppedRefImages: 0 }
62
+ }
63
+
64
+ // Reference mode: keep both frames (explicit intent), drop trailing user images
65
+ // if the 9-image cap is exceeded. Frames are appended AFTER the kept user
66
+ // images so existing user @Image ordinals are preserved.
67
+ const frameCount = (firstFrameUrl ? 1 : 0) + (lastFrameUrl ? 1 : 0)
68
+ const userImageSlots = Math.max(0, SEEDANCE_2_REF_LIMITS.images - frameCount)
69
+ const keptUserImages = refImages.slice(0, userImageSlots)
70
+ const droppedRefImages = refImages.length - keptUserImages.length
71
+
72
+ const referenceImageUrls: string[] = [...keptUserImages]
73
+ let firstOrdinal = 0
74
+ let lastOrdinal = 0
75
+ if (firstFrameUrl) { referenceImageUrls.push(firstFrameUrl); firstOrdinal = referenceImageUrls.length }
76
+ if (lastFrameUrl) { referenceImageUrls.push(lastFrameUrl); lastOrdinal = referenceImageUrls.length }
77
+
78
+ let promptSuffix = ""
79
+ if (firstOrdinal > 0 && lastOrdinal > 0) {
80
+ // Combined sentence — REF_BINDING.frame() emits a single-frame sentence, so use
81
+ // REF_BINDING.ordinal() for each ordinal inline to keep the combined wording AND
82
+ // route both ordinals through the single swap-point.
83
+ promptSuffix = `Use ${REF_BINDING.ordinal(firstOrdinal)} as the opening (first) frame and ${REF_BINDING.ordinal(lastOrdinal)} as the closing (last) frame of the video.`
84
+ } else if (firstOrdinal > 0) {
85
+ promptSuffix = REF_BINDING.frame(firstOrdinal, "opening")
86
+ } else if (lastOrdinal > 0) {
87
+ promptSuffix = REF_BINDING.frame(lastOrdinal, "closing")
88
+ }
89
+
90
+ return {
91
+ mode: "reference",
92
+ firstFrameUrl: undefined,
93
+ lastFrameUrl: undefined,
94
+ referenceImageUrls,
95
+ referenceVideoUrls: refVideos,
96
+ referenceAudioUrls: refAudios,
97
+ promptSuffix,
98
+ droppedRefImages,
99
+ }
100
+ }
package/src/setting.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Canonical catalog of place/environment presets ("Setting").
3
+ *
4
+ * Setting dimension of an image/video — *where* the shot takes place (coffee
5
+ * shop, forest clearing, cyberpunk alley, cathedral, etc.). Orthogonal to the
6
+ * other cinematography dimensions:
7
+ *
8
+ * - Style = artistic medium (Oil Painting, Pixar 3D, Photorealistic)
9
+ * - Atmosphere = what's in the air (Fog, Rain, God rays, Dust)
10
+ * - Lighting = direction / quality of light
11
+ * - Setting = *where* (this file)
12
+ *
13
+ * Not to be confused with the Location **entity** node, which generates a
14
+ * persistent reference image for a specific place via an AI provider call.
15
+ * Setting is pure prompt text (zero credits, zero API calls), deterministic.
16
+ *
17
+ * Shared between the picker UI, the standalone Setting parameter node, and
18
+ * the prompt-hint injection on both the frontend DAG executor and the
19
+ * backend orchestrator.
20
+ */
21
+
22
+ export type SettingCategory = "indoor" | "urban" | "nature" | "fantastical"
23
+
24
+ export interface Setting {
25
+ readonly id: string
26
+ readonly label: string
27
+ readonly category: SettingCategory
28
+ readonly description: string
29
+ readonly promptHint: string
30
+ }
31
+
32
+ export const SETTINGS: ReadonlyArray<Setting> = [
33
+ // -------------------- Indoor --------------------
34
+ { id: "coffee-shop", label: "Coffee Shop", category: "indoor", description: "Cozy café interior", promptHint: "set in a cozy coffee shop interior with warm pendant lights, exposed brick walls and steam drifting over espresso machines" },
35
+ { id: "library", label: "Library", category: "indoor", description: "Grand library with tall shelves", promptHint: "set in a grand library interior with tall oak shelves, leather-bound books, warm reading lamps and a tall arched window" },
36
+ { id: "office", label: "Modern Office", category: "indoor", description: "Bright glassy modern office", promptHint: "set in a bright modern office with floor-to-ceiling windows, minimalist desks, potted plants and soft ambient daylight" },
37
+ { id: "home-office", label: "Home Office", category: "indoor", description: "Cozy home workspace", promptHint: "set in a cozy home office with a wooden desk, laptop and monitor, warm task lamp, bookshelves, houseplants and soft daylight through a nearby window" },
38
+ { id: "bedroom", label: "Bedroom", category: "indoor", description: "Intimate bedroom", promptHint: "set in an intimate bedroom with soft diffused window light, rumpled linen bedding and warm wood floors" },
39
+ { id: "living-room", label: "Living Room", category: "indoor", description: "Cozy residential living room", promptHint: "set in a cozy residential living room with a plush sofa, wooden coffee table, soft lamplight, bookshelves and a warm family-home atmosphere" },
40
+ { id: "kitchen", label: "Kitchen", category: "indoor", description: "Warm home kitchen with morning light", promptHint: "set in a warm home kitchen with morning light, wood cabinets, a marble island with fresh produce and steam rising from a stovetop" },
41
+ { id: "hotel-room", label: "Hotel Room", category: "indoor", description: "Elegant hotel room with city view", promptHint: "set in an elegant hotel room with a neatly made king bed, heavy drapes, a panoramic city view through the window, minimalist desk and soft warm lamplight" },
42
+ { id: "restaurant", label: "Restaurant", category: "indoor", description: "Intimate candlelit restaurant", promptHint: "set in an intimate restaurant interior with candlelit tables, white linen, leather booths, wine glasses catching warm light and a softly bustling dining room" },
43
+ { id: "nightclub", label: "Nightclub", category: "indoor", description: "Dark club with lasers and smoke", promptHint: "set in a dark nightclub interior with a packed dance floor, laser and strobe lighting, thumping bass energy, silhouetted bodies and hazy atmospheric smoke" },
44
+ { id: "gym", label: "Gym", category: "indoor", description: "Modern fitness gym", promptHint: "set in a modern fitness gym with rubber flooring, racks of weights, mirrored walls and bright overhead lights on training equipment" },
45
+ { id: "classroom", label: "Classroom", category: "indoor", description: "Bright school classroom", promptHint: "set in a bright school classroom with rows of wooden desks, a whiteboard covered in notes, posters on the walls and afternoon sun through tall windows" },
46
+ { id: "hospital", label: "Hospital", category: "indoor", description: "Sterile hospital corridor", promptHint: "set in a sterile hospital corridor with polished linoleum floors, fluorescent ceiling lights, stainless-steel gurneys and softly beeping medical equipment" },
47
+ { id: "laboratory", label: "Laboratory", category: "indoor", description: "Research lab with glowing equipment", promptHint: "set in a modern research laboratory with stainless-steel benches, glowing microscopes, racks of beakers and test tubes, overhead task lights and faint blue ambient glow from monitors" },
48
+ { id: "courtroom", label: "Courtroom", category: "indoor", description: "Wood-paneled courtroom", promptHint: "set in a formal wood-paneled courtroom with rows of oak benches, a raised judge's bench, a lectern, flags on either side and amber light streaming through tall windows" },
49
+ { id: "warehouse", label: "Industrial Warehouse", category: "indoor", description: "Cavernous warehouse with skylights", promptHint: "set in a cavernous industrial warehouse with steel rafters, concrete floors and dust motes in shafts of light from skylights above" },
50
+ { id: "subway-car", label: "Subway Car", category: "indoor", description: "Moving subway interior", promptHint: "set inside a moving subway car with fluorescent strip lights, graffiti-scarred windows and empty blue plastic seats" },
51
+ { id: "taxi", label: "Taxi Interior", category: "indoor", description: "Back seat of a city taxi at night", promptHint: "set inside the back seat of a city taxi at night with rain-streaked windows, neon reflections sliding across the dashboard, the driver silhouetted against the windshield and the dim green glow of the fare meter" },
52
+ { id: "cathedral", label: "Cathedral", category: "indoor", description: "Gothic cathedral interior", promptHint: "set in a vast gothic cathedral interior with vaulted stone ceilings, stained-glass kaleidoscopes and candlelit side chapels" },
53
+ { id: "art-gallery", label: "Art Gallery", category: "indoor", description: "Minimalist white-cube gallery", promptHint: "set in a minimalist white-cube art gallery with polished concrete floor, precision track lighting and framed canvases on bare walls" },
54
+ { id: "balcony", label: "Balcony", category: "indoor", description: "Apartment balcony with urban view", promptHint: "set on an apartment balcony with potted plants along the railing, a sweeping urban view beyond, soft daylight and a candid intimate atmosphere of a private outdoor pocket above the city" },
55
+ { id: "attic", label: "Attic", category: "indoor", description: "Wooden-beam dusty attic", promptHint: "set in a wooden-beam dusty attic with a slanted roof, exposed rafters, found objects in cardboard boxes, draped sheets over old furniture and a single shaft of light through a small dormer window" },
56
+ { id: "basement", label: "Basement", category: "indoor", description: "Concrete basement with exposed pipes", promptHint: "set in a concrete basement with exposed overhead pipes, a bare hanging bulb, dim industrial vibe, water stains on the walls and stacked storage crates in the shadows" },
57
+ { id: "sauna", label: "Sauna", category: "indoor", description: "Wood-paneled sauna with rising steam", promptHint: "set in a wood-paneled sauna with rising steam clouds, intimate warm sweaty space lit by amber bulbs, condensation on glass and cedar slat benches glistening with moisture" },
58
+ { id: "dorm-room", label: "Dorm Room", category: "indoor", description: "Cluttered college dorm with fairy lights", promptHint: "set in a cluttered college dorm room with a twin bed, posters taped to the walls, fairy lights strung along the ceiling, a cramped desk piled with books and an intimate lived-in glow" },
59
+ { id: "locker-room", label: "Locker Room", category: "indoor", description: "Tiled gym locker room with benches", promptHint: "set in a tiled gym locker room with rows of metal lockers, wooden benches down the center, mirrors above the sinks, post-workout steam in the air and the harsh hum of overhead fluorescents" },
60
+ { id: "music-studio", label: "Music Studio", category: "indoor", description: "Recording studio with mics and foam", promptHint: "set in a recording music studio with a vocal microphone on a boom arm, soundproof foam panels on the walls, a large mixing control board, monitor speakers and warm low task lighting" },
61
+ { id: "conservatory", label: "Conservatory", category: "indoor", description: "Glass greenhouse with tropical plants", promptHint: "set inside a glass-walled conservatory greenhouse with tropical plants, hanging ferns, terracotta pots, filtered sunlight pouring through the panes and a humid earthy atmosphere" },
62
+
63
+ // -------------------- Urban --------------------
64
+ { id: "city-street", label: "City Street", category: "urban", description: "Bustling city street", promptHint: "set on a bustling city street with pedestrians, traffic, reflections on wet asphalt and mid-rise commercial facades" },
65
+ { id: "rooftop", label: "Rooftop", category: "urban", description: "Rooftop terrace over skyline", promptHint: "set on a rooftop terrace overlooking a dense city skyline with string lights, distant car horns and hazy sunset light" },
66
+ { id: "back-alley", label: "Back Alley", category: "urban", description: "Gritty narrow alley", promptHint: "set in a narrow urban back alley with dumpsters, fire escapes, overflowing rain gutters and a single flickering wall sconce" },
67
+ { id: "neon-alley", label: "Neon Alley", category: "urban", description: "Rain-soaked neon alley", promptHint: "set in a rain-soaked neon-lit alley in a Tokyo-style nightlife district with glowing kanji signs, steam vents and reflective puddles" },
68
+ { id: "park", label: "Urban Park", category: "urban", description: "Leafy urban park with paths", promptHint: "set in a leafy urban park with winding footpaths, wooden benches, tall shade trees, people lounging on grass and sunlight filtering through leaves" },
69
+ { id: "backyard", label: "Backyard Patio", category: "urban", description: "Deck patio with string lights", promptHint: "set in a warm backyard patio with a wooden deck, string lights overhead, lounge chairs, green lawn beyond a low fence and the soft golden light of a late summer afternoon" },
70
+ { id: "highway", label: "Open Highway", category: "urban", description: "Sweeping highway to horizon", promptHint: "set on a sweeping open highway with lane markings stretching to the horizon, rolling hills on either side, a single car on the road and a vast cinematic sky" },
71
+ { id: "bridge", label: "Suspension Bridge", category: "urban", description: "Long suspension bridge over water", promptHint: "set on a long suspension bridge with towering steel cables, sweeping views over water, evening glow on the railings and a distant city skyline beyond" },
72
+ { id: "train-station", label: "Train Station", category: "urban", description: "Platform with waiting train", promptHint: "set on a long urban train station platform with polished steel rails, a waiting train with warmly lit windows, overhead station signs, drifting steam and the lonely echo of footsteps on stone" },
73
+ { id: "airport", label: "Airport Terminal", category: "urban", description: "Vast terminal with curved glass", promptHint: "set in a vast airport terminal with gleaming polished floors, flight information boards, travelers with rolling luggage, curved glass walls and the soft drone of distant announcements" },
74
+ { id: "parking-lot", label: "Parking Lot", category: "urban", description: "Suburban parking lot at dusk", promptHint: "set in an empty suburban parking lot at dusk with sodium-vapor lamps casting orange pools, scattered shopping carts and painted lane lines" },
75
+ { id: "penthouse", label: "Penthouse", category: "urban", description: "Luxury penthouse with skyline view", promptHint: "set in a luxury penthouse interior with panoramic skyline views, marble floors, modernist furniture and low warm ambient light" },
76
+ { id: "gas-station", label: "Gas Station", category: "urban", description: "Lonely highway gas station at night", promptHint: "set at a lonely highway gas station at night with a fluorescent canopy, bug-swarmed sodium lamps and cracked asphalt" },
77
+
78
+ // -------------------- Nature --------------------
79
+ { id: "forest", label: "Forest Clearing", category: "nature", description: "Sunlit mossy clearing", promptHint: "set in a sunlit forest clearing with moss-covered stones, dappled light through tall trees and a soft carpet of fallen leaves" },
80
+ { id: "beach", label: "Beach", category: "nature", description: "Wide sandy beach with surf", promptHint: "set on a wide sandy beach with gentle breaking surf, footprints in wet sand and a pastel horizon" },
81
+ { id: "mountain-peak", label: "Mountain Peak", category: "nature", description: "Rocky alpine summit", promptHint: "set on a dramatic rocky mountain peak above the cloud line with a sweeping alpine vista, windblown snow and crisp thin-air light" },
82
+ { id: "desert", label: "Desert Dunes", category: "nature", description: "Windblown desert dunes", promptHint: "set among endless windblown desert dunes with rippled sand patterns, heat haze on the horizon and a merciless open sky" },
83
+ { id: "jungle", label: "Jungle", category: "nature", description: "Dense humid jungle interior", promptHint: "set in a dense humid jungle interior with hanging vines, giant ferns, distant birdcalls and emerald light filtering through the canopy" },
84
+ { id: "grassland", label: "Grassland", category: "nature", description: "Open windswept grassland", promptHint: "set in an open windswept grassland under a huge sky with swaying tall grass, scattered wildflowers and distant rolling hills" },
85
+ { id: "snowy-tundra", label: "Snowy Tundra", category: "nature", description: "Frozen wind-carved tundra", promptHint: "set in a vast frozen tundra with wind-carved snow drifts, low arctic sun, long blue shadows and scattered ice-crusted stones" },
86
+ { id: "lake-shore", label: "Lake Shore", category: "nature", description: "Still mountain lake shoreline", promptHint: "set on a still mountain-lake shoreline with mirror reflections, smooth pebbles, wispy morning mist and a line of dark conifers" },
87
+ { id: "riverbank", label: "Riverbank", category: "nature", description: "Meandering river with willow trees", promptHint: "set on a meandering riverbank with slow flowing water, smooth pebbles along the shore, willow trees leaning over the banks and dappled sunlight rippling on the current" },
88
+ { id: "waterfall", label: "Waterfall", category: "nature", description: "Cascading falls over mossy cliffs", promptHint: "set at a thundering waterfall cascading over mossy cliffs into a misty pool, rainbows in the spray, an emerald basin below and lush ferns clinging to wet rocks" },
89
+ { id: "cave", label: "Cave", category: "nature", description: "Rocky cave with daylight shafts", promptHint: "set inside a vast rocky cave with dripping stalactites, pools of still water, shafts of daylight piercing the darkness from above and dark mossy walls slick with moisture" },
90
+ { id: "western-canyon", label: "Western Canyon", category: "nature", description: "Red-rock mesa with a winding river", promptHint: "set in a sweeping Southwestern canyon landscape with red sandstone mesas, a winding river cutting through the valley floor, cottonwood groves, open arid plains and the wide cinematic sky of a classic Western" },
91
+
92
+ // -------------------- Fantastical --------------------
93
+ { id: "alien-planet", label: "Alien Planet", category: "fantastical", description: "Otherworldly landscape with twin moons", promptHint: "set on an otherworldly alien planet landscape with bioluminescent flora, twin moons in a violet sky and iridescent rock formations" },
94
+ { id: "spaceship-interior", label: "Spaceship Interior", category: "fantastical", description: "Sleek starship corridor", promptHint: "set inside a sleek spaceship interior with curved corridors, illuminated control panels, softly humming machinery and a panoramic view of stars through a reinforced window" },
95
+ { id: "underwater", label: "Underwater", category: "fantastical", description: "Sunlit deep-ocean scene", promptHint: "set in a deep-ocean underwater scene with shafts of sunlight piercing blue-green water, drifting particles and dim coral shapes" },
96
+ { id: "fantasy-castle", label: "Fantasy Castle", category: "fantastical", description: "Sprawling castle courtyard", promptHint: "set in a sprawling fantasy castle courtyard with weathered stone walls, banners, torches in iron sconces and wrought-iron gates" },
97
+ { id: "medieval-village", label: "Medieval Village", category: "fantastical", description: "Cobblestone village square", promptHint: "set in a cobblestone medieval village square with timber-framed houses, smoking chimneys, a stone well, market stalls and warm amber lantern light" },
98
+ { id: "ancient-ruins", label: "Ancient Ruins", category: "fantastical", description: "Vine-choked stone ruins", promptHint: "set among vine-choked ancient stone ruins with toppled pillars, weathered carvings, shafts of golden light piercing overgrown jungle and the weight of a long-forgotten civilization" },
99
+ { id: "cyberpunk-city", label: "Cyberpunk City", category: "fantastical", description: "Sprawling neon megacity skyline", promptHint: "set on an elevated walkway over a sprawling cyberpunk megacity skyline at night with towering neon-clad skyscrapers, holographic billboards, flying traffic and a pink haze blanketing the grid" },
100
+ { id: "haunted-mansion", label: "Haunted Mansion", category: "fantastical", description: "Decaying gothic manor", promptHint: "set inside a decaying gothic haunted mansion with cobwebbed chandeliers, warped wooden floors, dust-covered furniture and pale moonlight through cracked windows" },
101
+ { id: "dreamscape", label: "Dreamscape", category: "fantastical", description: "Surreal floating islands", promptHint: "set in a surreal dreamscape with floating islands, impossible architecture, pastel mist and skies that shift between day and night" },
102
+ { id: "wasteland", label: "Post-Apocalyptic Wasteland", category: "fantastical", description: "Rusted overcast wasteland", promptHint: "set in a bleak post-apocalyptic wasteland with rusted vehicles, broken overpasses, grey ash drifts and a perpetually overcast sky" },
103
+ ] as const
104
+
105
+ const settingById = new Map<string, Setting>(SETTINGS.map((s) => [s.id, s]))
106
+
107
+ export function getSetting(id: string | undefined | null): Setting | undefined {
108
+ if (!id) return undefined
109
+ return settingById.get(id)
110
+ }
111
+
112
+ export function getSettingLabel(id: string | undefined | null, fallback?: string): string {
113
+ const s = getSetting(id)
114
+ if (s) return s.label
115
+ if (fallback !== undefined) return fallback
116
+ return (id ?? "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
117
+ }
118
+
119
+ export function getSettingPromptHint(id: string | undefined | null): string {
120
+ return getSetting(id)?.promptHint ?? ""
121
+ }
122
+
123
+ export const SETTING_IDS: ReadonlyArray<string> = SETTINGS.map((s) => s.id)
124
+
125
+ export const SETTING_CATEGORY_LABELS: Readonly<Record<SettingCategory, string>> = {
126
+ indoor: "Indoor",
127
+ urban: "Urban",
128
+ nature: "Nature",
129
+ fantastical: "Fantastical",
130
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Aggregator that walks a consumer node's `audio-style` target handle, collects
3
+ * incoming Sound parameter nodes, and composes a SoundComposition (text +
4
+ * optional structured fields + warnings) used to enrich the consumer's prompt
5
+ * fields. Parallel to how cinematography hints aggregate via
6
+ * `collectCinematographyHints` in front+back, but exposes structured-field
7
+ * outputs for typed targets like MiniMax (genre/mood/instrumental).
8
+ *
9
+ * Called from:
10
+ * - frontend: `frontend/src/lib/audio-style-hints.ts` (canvas executor)
11
+ * - backend: `backend/src/services/workflow-engine/payload-builder.ts`
12
+ */
13
+
14
+ import type { HintGraphContext, HintNodeLike } from "./parameter-prompt-hint.js"
15
+ import { getParameterPromptHint } from "./parameter-prompt-hint.js"
16
+ import { getMusicGenre, getMusicEra } from "./music-genre.js"
17
+ import { buildMusicMoodHints } from "./music-mood.js"
18
+
19
+ export type SoundConsumerType =
20
+ | "suno-generate"
21
+ | "generate-music"
22
+ | "voice-design"
23
+ | "voice-remix"
24
+ | "text-to-audio"
25
+
26
+ export interface SoundCompositionFields {
27
+ readonly genre?: string
28
+ readonly mood?: string
29
+ readonly instrumental?: boolean
30
+ readonly voiceDescription?: string
31
+ /**
32
+ * Suno's binary vocal-gender control. Extracted from a connected
33
+ * voice-character node's `gender` field when it's "male" or "female"
34
+ * ("androgynous" is left to prompt-only since Suno doesn't accept it).
35
+ * Set on suno-generate and generate-music consumers.
36
+ */
37
+ readonly vocalGender?: "male" | "female"
38
+ }
39
+
40
+ export interface SoundComposition {
41
+ readonly text: string
42
+ readonly fields: SoundCompositionFields
43
+ readonly warnings: ReadonlyArray<string>
44
+ }
45
+
46
+ const MUSIC_TYPES = new Set(["music-genre", "music-mood", "instrumentation"])
47
+ const VOICE_TYPES = new Set(["voice-character", "voice-delivery"])
48
+
49
+ function isMusic(t: string | undefined) { return !!t && MUSIC_TYPES.has(t) }
50
+ function isVoice(t: string | undefined) { return !!t && VOICE_TYPES.has(t) }
51
+
52
+ /**
53
+ * Target handles whose wired pickers contribute an audio-style hint. Before the
54
+ * typed-handle split, suno-generate had ONE picker handle; the split into
55
+ * prompt / audio-style / voice fragmented collection, so a descriptive
56
+ * voice-character picker dropped onto the "Voice" pip silently vanished from the
57
+ * output. The voice pip's other accepted sources (suno-voice / voice-design) are
58
+ * persona producers — `getParameterPromptHint` returns "" for them, so they're
59
+ * skipped here and still route to `personaId` via the input-resolver. Only
60
+ * suno-generate exposes a `voice` handle among the fold consumers, so adding it
61
+ * here is naturally scoped to suno without affecting generate-music / voice-* /
62
+ * text-to-audio (they have no voice edges to collect).
63
+ */
64
+ const AUDIO_STYLE_SOURCE_HANDLES: ReadonlySet<string> = new Set(["audio-style", "voice"])
65
+
66
+ function collectAudioStyleSources(
67
+ consumer: HintNodeLike,
68
+ ctx: HintGraphContext,
69
+ ): HintNodeLike[] {
70
+ const sources: HintNodeLike[] = []
71
+ for (const edge of ctx.edges) {
72
+ if (edge.target !== consumer.id) continue
73
+ if (edge.targetHandle == null || !AUDIO_STYLE_SOURCE_HANDLES.has(edge.targetHandle)) continue
74
+ const source = ctx.nodes.find((n) => n.id === edge.source)
75
+ if (source) sources.push(source)
76
+ }
77
+ return sources
78
+ }
79
+
80
+ export function composeSoundHintFromConnections(
81
+ consumer: HintNodeLike,
82
+ consumerType: SoundConsumerType,
83
+ ctx: HintGraphContext,
84
+ ): SoundComposition {
85
+ const sources = collectAudioStyleSources(consumer, ctx)
86
+ if (sources.length === 0) {
87
+ return { text: "", fields: {}, warnings: [] }
88
+ }
89
+
90
+ const warnings: string[] = []
91
+ const acceptedHints: string[] = []
92
+ const fields: SoundCompositionFields = {}
93
+
94
+ // Identify which sources are accepted vs warned-about by consumer type.
95
+ //
96
+ // Music consumers (suno-generate, generate-music) accept BOTH music nodes
97
+ // AND voice nodes — voice description (gender, age, accent, language,
98
+ // timbre, delivery archetype) is valid input for music with vocals. Suno
99
+ // V5 in particular benefits from rich voice description; the typed
100
+ // `vocalGender` field on Suno is also extracted below from voice-character.
101
+ //
102
+ // Voice Design rejects music nodes (different domain). Text-to-Audio
103
+ // (sound effects) rejects voice nodes (sound effects aren't vocal).
104
+ for (const src of sources) {
105
+ const t = src.type
106
+ if (consumerType === "voice-design" || consumerType === "voice-remix") {
107
+ if (isMusic(t)) {
108
+ warnings.push(`Music nodes (${t}) are ignored on ${consumerType === "voice-design" ? "Voice Design" : "Voice Remix"}.`)
109
+ continue
110
+ }
111
+ }
112
+ if (consumerType === "text-to-audio") {
113
+ if (isVoice(t)) {
114
+ warnings.push(`Voice nodes (${t}) are ignored on Text to Audio.`)
115
+ continue
116
+ }
117
+ }
118
+
119
+ const hint = getParameterPromptHint(src)
120
+ if (hint) acceptedHints.push(hint)
121
+
122
+ // Music consumers — extract vocalGender from connected voice-character
123
+ // node (when gender is "male" or "female"; "androgynous" doesn't map to
124
+ // Suno's binary vocalGender field, so it's left to the prompt-only path).
125
+ // Applies to BOTH suno-generate and generate-music since the Suno
126
+ // provider routes both.
127
+ if (
128
+ (consumerType === "suno-generate" || consumerType === "generate-music") &&
129
+ t === "voice-character" &&
130
+ src.data
131
+ ) {
132
+ const gender = (src.data as Record<string, unknown>).gender
133
+ if ((gender === "male" || gender === "female") && !fields.vocalGender) {
134
+ Object.assign(fields, { vocalGender: gender })
135
+ }
136
+ }
137
+
138
+ // Generate Music — populate typed fields when provider is minimax.
139
+ if (consumerType === "generate-music" && consumer.data && (consumer.data as Record<string, unknown>).provider === "minimax") {
140
+ const data = src.data as Record<string, unknown> | undefined
141
+ if (t === "music-genre" && data) {
142
+ const sub = typeof data.subgenre === "string" ? data.subgenre : undefined
143
+ const genreId = typeof data.genre === "string" ? data.genre : undefined
144
+ const eraId = typeof data.era === "string" ? data.era : undefined
145
+ const eraHint = getMusicEra(eraId)?.promptHint
146
+ const genre = getMusicGenre(genreId)
147
+ const subHint = genre?.subgenres.find((s) => s.id === sub)?.promptHint ?? genre?.promptHint
148
+ const composed = [eraHint, subHint].filter(Boolean).join(" ")
149
+ if (composed && !fields.genre) Object.assign(fields, { genre: composed })
150
+ }
151
+ if (t === "music-mood" && data) {
152
+ const composed = buildMusicMoodHints({
153
+ energy: typeof data.energy === "string" ? data.energy : undefined,
154
+ emotion: data.emotion as string | ReadonlyArray<string> | undefined,
155
+ vibe: data.vibe as string | ReadonlyArray<string> | undefined,
156
+ })
157
+ if (composed && !fields.mood) Object.assign(fields, { mood: composed })
158
+ }
159
+ if (t === "instrumentation" && data) {
160
+ const vp = typeof data.vocalPresence === "string" ? data.vocalPresence : undefined
161
+ if (vp === "instrumental") Object.assign(fields, { instrumental: true })
162
+ }
163
+ }
164
+ }
165
+
166
+ const text = acceptedHints.join(", ")
167
+
168
+ // Voice Design / Voice Remix: also surface the composed text as
169
+ // voiceDescription so callers can drop it straight into the API field.
170
+ if ((consumerType === "voice-design" || consumerType === "voice-remix") && text) {
171
+ Object.assign(fields, { voiceDescription: text })
172
+ }
173
+
174
+ return { text, fields, warnings }
175
+ }
176
+
177
+ /**
178
+ * Append `composedText` to `userText` with ", " separator. Empty inputs
179
+ * are tolerated; the non-empty side is returned alone.
180
+ */
181
+ export function appendField(userText: string, composedText: string): string {
182
+ if (!composedText) return userText
183
+ if (!userText) return composedText
184
+ return `${userText}, ${composedText}`
185
+ }
186
+
187
+ /**
188
+ * Truncate `composedText` so the final string `userText + ", " + composedText`
189
+ * fits within `maxTotalLen`. Returns "" when the budget is non-positive (user
190
+ * text already at limit). Cuts on word boundary when possible.
191
+ */
192
+ export function truncateForField(composedText: string, userText: string, maxTotalLen: number): string {
193
+ if (!composedText) return ""
194
+ const sepLen = userText.length > 0 ? 2 : 0
195
+ const budget = maxTotalLen - userText.length - sepLen
196
+ if (budget <= 0) return ""
197
+ if (composedText.length <= budget) return composedText
198
+ const cut = composedText.slice(0, budget)
199
+ const ws = cut.lastIndexOf(" ")
200
+ return ws > 0 ? cut.slice(0, ws) : cut
201
+ }
202
+
203
+ /**
204
+ * Suno Generate auto-detects custom mode when the user typed style/title/lyrics,
205
+ * even without explicitly toggling `customMode`. The frontend executor, backend
206
+ * payload-builder, and the FinalAudioPromptPreview all need to agree on the
207
+ * resolved value or the preview lies about which field receives the audio-style
208
+ * hint.
209
+ *
210
+ * Accepts an unknown-keyed record so callers in both the typed frontend
211
+ * (SunoGenerateData) and the loosely-typed backend (WorkflowNodeData with
212
+ * `[k: string]: unknown`) can hand the node `data` straight in without casts.
213
+ */
214
+ export function getEffectiveSunoCustomMode(
215
+ data: { readonly [k: string]: unknown },
216
+ ): boolean {
217
+ if (typeof data.customMode === "boolean") return data.customMode
218
+ return !!(data.style || data.title || data.lyrics)
219
+ }
220
+
221
+ /**
222
+ * Fold a Generate Music node's genre / mood / instrumental selections into the
223
+ * prompt text. The music worker only reads `prompt`, so this enrichment is the
224
+ * ONLY channel by which those three controls reach the provider.
225
+ *
226
+ * Shared by the single-node route (`/v1/generate-music`) and the workflow
227
+ * orchestrator (`payload-builder.ts` `generate-music`) so a workflow / app /
228
+ * webhook run produces the same prompt — and the same music — as a Run-button
229
+ * run. Mirrors the route's original inline `[prompt, genre, mood, …].join(", ")`
230
+ * exactly (locked by `routes/__tests__/generate-music.test.ts`).
231
+ */
232
+ export function appendMusicMeta(
233
+ basePrompt: string,
234
+ meta: { genre?: string; mood?: string; instrumental?: boolean },
235
+ ): string {
236
+ const parts = [basePrompt]
237
+ if (meta.genre) parts.push(meta.genre)
238
+ if (meta.mood) parts.push(meta.mood)
239
+ if (meta.instrumental) parts.push("instrumental, no vocals")
240
+ return parts.join(", ")
241
+ }