@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.
- package/LICENSE +105 -0
- package/README.md +27 -0
- package/dist/index.cjs +21240 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3599 -0
- package/dist/index.d.ts +3599 -0
- package/dist/index.js +20854 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/__tests__/__snapshots__/prompt-builder-segments.test.ts.snap +65 -0
- package/src/__tests__/action-fx.test.ts +155 -0
- package/src/__tests__/apply-picker-json.test.ts +50 -0
- package/src/__tests__/assemble-image-input.test.ts +248 -0
- package/src/__tests__/assemble-suno-input.test.ts +283 -0
- package/src/__tests__/brand-tokens.test.ts +81 -0
- package/src/__tests__/build-image-prompt-element-injection.test.ts +118 -0
- package/src/__tests__/build-image-prompt-hybrid-format.test.ts +131 -0
- package/src/__tests__/build-image-prompt-mentions.test.ts +809 -0
- package/src/__tests__/build-image-prompt-reference-cap.test.ts +57 -0
- package/src/__tests__/build-image-prompt-reference-numbering.test.ts +237 -0
- package/src/__tests__/build-image-prompt-reference-order.test.ts +313 -0
- package/src/__tests__/camera-motions-from-connections.test.ts +71 -0
- package/src/__tests__/catalog-gapfill.test.ts +53 -0
- package/src/__tests__/character-convergence-image.test.ts +217 -0
- package/src/__tests__/character-default-role-image.test.ts +167 -0
- package/src/__tests__/character-default-role-video.test.ts +168 -0
- package/src/__tests__/character-default-role.test.ts +70 -0
- package/src/__tests__/character-fx.test.ts +200 -0
- package/src/__tests__/entity-prompts-location.test.ts +97 -0
- package/src/__tests__/entity-prompts.test.ts +240 -0
- package/src/__tests__/expand-extra-refs-role.test.ts +50 -0
- package/src/__tests__/factory-presets.test.ts +1045 -0
- package/src/__tests__/factory-snippets.test.ts +68 -0
- package/src/__tests__/framing-multi.test.ts +71 -0
- package/src/__tests__/framing-vantage.test.ts +39 -0
- package/src/__tests__/i18n-entry-completeness.test.ts +269 -0
- package/src/__tests__/identity-lock.test.ts +46 -0
- package/src/__tests__/instrumentation.test.ts +68 -0
- package/src/__tests__/lighting-multi.test.ts +68 -0
- package/src/__tests__/location-convergence-image.test.ts +124 -0
- package/src/__tests__/mention-lock-flag.test.ts +499 -0
- package/src/__tests__/multi-picker-spec.test.ts +71 -0
- package/src/__tests__/music-genre.test.ts +103 -0
- package/src/__tests__/music-mood.test.ts +87 -0
- package/src/__tests__/object-creature-convergence-image.test.ts +105 -0
- package/src/__tests__/parameter-prompt-hint.test.ts +270 -0
- package/src/__tests__/parameter-registry-sync.test.ts +243 -0
- package/src/__tests__/person-age.test.ts +80 -0
- package/src/__tests__/person-analyzer-invariants.test.ts +47 -0
- package/src/__tests__/person-body-axes.test.ts +67 -0
- package/src/__tests__/person-facial-geometry.test.ts +156 -0
- package/src/__tests__/person-regional-aesthetic.test.ts +161 -0
- package/src/__tests__/person-sections.test.ts +18 -0
- package/src/__tests__/picker-analyzer-registry.test.ts +108 -0
- package/src/__tests__/picker-catalogs-project.test.ts +85 -0
- package/src/__tests__/picker-catalogs.test.ts +53 -0
- package/src/__tests__/picker-limits.test.ts +20 -0
- package/src/__tests__/prompt-builder-segments.test.ts +183 -0
- package/src/__tests__/prompt-builder-structured-fields.test.ts +40 -0
- package/src/__tests__/prompt-builder.test.ts +1773 -0
- package/src/__tests__/prompt-wizard-categories.test.ts +31 -0
- package/src/__tests__/provider-prompt-doctrine.test.ts +49 -0
- package/src/__tests__/resolve-prompt-append.test.ts +58 -0
- package/src/__tests__/resolve-prompt.test.ts +44 -0
- package/src/__tests__/role-picker-shared.test.ts +208 -0
- package/src/__tests__/seedance-2-inputs.test.ts +173 -0
- package/src/__tests__/seedance-extend.test.ts +44 -0
- package/src/__tests__/sound-aggregator.test.ts +350 -0
- package/src/__tests__/style-presets.test.ts +34 -0
- package/src/__tests__/temporal-multi.test.ts +74 -0
- package/src/__tests__/transitions.test.ts +213 -0
- package/src/__tests__/video-reference-features.test.ts +41 -0
- package/src/__tests__/video-reference-leading-refs.test.ts +90 -0
- package/src/__tests__/video-reference-resolver.test.ts +233 -0
- package/src/__tests__/video-reference-roles.test.ts +96 -0
- package/src/__tests__/voice-character.test.ts +48 -0
- package/src/__tests__/voice-delivery.test.ts +41 -0
- package/src/__tests__/wardrobe.test.ts +25 -0
- package/src/action-fx.ts +255 -0
- package/src/aesthetic.ts +435 -0
- package/src/assemble-image-input.ts +236 -0
- package/src/assemble-suno-input.ts +147 -0
- package/src/atmosphere.ts +104 -0
- package/src/backdrop.ts +131 -0
- package/src/brand-tokens.ts +154 -0
- package/src/camera-format.ts +76 -0
- package/src/camera-motions.ts +615 -0
- package/src/character-fx.ts +274 -0
- package/src/color-look.ts +103 -0
- package/src/composition-effects.ts +67 -0
- package/src/entity-prompts.ts +231 -0
- package/src/era.ts +292 -0
- package/src/exposure-settings.ts +142 -0
- package/src/factory-presets/generate-image.ts +1644 -0
- package/src/factory-presets/generate-video.ts +1116 -0
- package/src/factory-presets/index.ts +46 -0
- package/src/factory-presets/lottie-overlay.ts +166 -0
- package/src/factory-presets/motion-graphics.ts +350 -0
- package/src/factory-presets/music.ts +734 -0
- package/src/factory-presets/sfx.ts +136 -0
- package/src/factory-presets/shared-image.ts +207 -0
- package/src/factory-presets/switchx.ts +65 -0
- package/src/factory-presets/text.ts +292 -0
- package/src/factory-presets/types.ts +51 -0
- package/src/factory-presets/video-edit.ts +136 -0
- package/src/factory-presets/voice.ts +172 -0
- package/src/factory-presets.ts +2 -0
- package/src/factory-snippets/catalog.ts +105 -0
- package/src/factory-snippets/index.ts +16 -0
- package/src/factory-snippets/types.ts +33 -0
- package/src/framing.ts +634 -0
- package/src/held-prop.ts +187 -0
- package/src/identity-lock.ts +213 -0
- package/src/index.ts +66 -0
- package/src/instrumentation.ts +337 -0
- package/src/lens.ts +59 -0
- package/src/lighting.ts +229 -0
- package/src/loop-subject.ts +276 -0
- package/src/materials.ts +184 -0
- package/src/mood.ts +186 -0
- package/src/music-genre.ts +662 -0
- package/src/music-mood.ts +137 -0
- package/src/object-asset-presets.ts +81 -0
- package/src/parameter-prompt-hint.ts +281 -0
- package/src/person.ts +1368 -0
- package/src/photo-genre.ts +151 -0
- package/src/photographer.ts +612 -0
- package/src/picker-analyzer-registry.ts +374 -0
- package/src/picker-catalogs.ts +858 -0
- package/src/pose.ts +246 -0
- package/src/post-process-effects.ts +94 -0
- package/src/prompt-builder-structured-fields.ts +116 -0
- package/src/prompt-builder.ts +2964 -0
- package/src/prompt-templates.ts +50 -0
- package/src/prompt-wizard-categories.ts +334 -0
- package/src/provider-prompt-doctrine.ts +85 -0
- package/src/render-quality.ts +89 -0
- package/src/resolve-prompt.ts +120 -0
- package/src/seedance-2-inputs.ts +100 -0
- package/src/setting.ts +130 -0
- package/src/sound-aggregator.ts +241 -0
- package/src/style-presets.ts +162 -0
- package/src/style.ts +99 -0
- package/src/styling.ts +585 -0
- package/src/temporal.ts +151 -0
- package/src/transitions.ts +333 -0
- package/src/video-reference-resolver.ts +823 -0
- package/src/voice-character.ts +237 -0
- package/src/voice-delivery.ts +142 -0
- 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
|
+
}
|