@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,350 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
composeSoundHintFromConnections,
|
|
4
|
+
type SoundComposition,
|
|
5
|
+
} from "../sound-aggregator.js"
|
|
6
|
+
import type { HintGraphContext, HintNodeLike, HintEdgeLike } from "../parameter-prompt-hint.js"
|
|
7
|
+
|
|
8
|
+
function ctx(nodes: HintNodeLike[], edges: HintEdgeLike[]): HintGraphContext {
|
|
9
|
+
return { nodes, edges }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const sunoConsumer = (data: Record<string, unknown> = {}): HintNodeLike => ({
|
|
13
|
+
id: "consumer", type: "suno-generate", data,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function audioStyleEdge(sourceId: string): HintEdgeLike {
|
|
17
|
+
return { source: sourceId, target: "consumer", sourceHandle: "out", targetHandle: "audio-style" }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function voiceEdge(sourceId: string): HintEdgeLike {
|
|
21
|
+
return { source: sourceId, target: "consumer", sourceHandle: "out", targetHandle: "voice" }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("composeSoundHintFromConnections — empty cases", () => {
|
|
25
|
+
it("returns empty composition with no edges", () => {
|
|
26
|
+
const out = composeSoundHintFromConnections(sunoConsumer(), "suno-generate", ctx([], []))
|
|
27
|
+
expect(out.text).toBe("")
|
|
28
|
+
expect(out.fields).toEqual({})
|
|
29
|
+
expect(out.warnings).toEqual([])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("ignores edges that don't target audio-style", () => {
|
|
33
|
+
const out = composeSoundHintFromConnections(
|
|
34
|
+
sunoConsumer(),
|
|
35
|
+
"suno-generate",
|
|
36
|
+
ctx(
|
|
37
|
+
[{ id: "src", type: "music-genre", data: { genre: "electronic" } }],
|
|
38
|
+
[{ source: "src", target: "consumer", targetHandle: "in" }],
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
expect(out.text).toBe("")
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("composeSoundHintFromConnections — Suno Generate", () => {
|
|
46
|
+
it("composes music nodes into text", () => {
|
|
47
|
+
const out = composeSoundHintFromConnections(
|
|
48
|
+
sunoConsumer({ customMode: true }),
|
|
49
|
+
"suno-generate",
|
|
50
|
+
ctx(
|
|
51
|
+
[
|
|
52
|
+
{ id: "g", type: "music-genre", data: { genre: "electronic", subgenre: "synthwave" } },
|
|
53
|
+
{ id: "m", type: "music-mood", data: { energy: "high", emotion: "triumphant" } },
|
|
54
|
+
],
|
|
55
|
+
[audioStyleEdge("g"), audioStyleEdge("m")],
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
expect(out.text).toContain("synthwave")
|
|
59
|
+
expect(out.text).toContain("triumphant")
|
|
60
|
+
expect(out.warnings).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("accepts voice-character nodes (Suno V5 supports vocal description)", () => {
|
|
64
|
+
const out = composeSoundHintFromConnections(
|
|
65
|
+
sunoConsumer({ customMode: true }),
|
|
66
|
+
"suno-generate",
|
|
67
|
+
ctx(
|
|
68
|
+
[{ id: "v", type: "voice-character", data: { timbre: "warm", gender: "female", accent: "british-rp" } }],
|
|
69
|
+
[audioStyleEdge("v")],
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
expect(out.text).toContain("warm")
|
|
73
|
+
expect(out.warnings).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("extracts voice-character.gender → fields.vocalGender (male)", () => {
|
|
77
|
+
const out = composeSoundHintFromConnections(
|
|
78
|
+
sunoConsumer({ customMode: true }),
|
|
79
|
+
"suno-generate",
|
|
80
|
+
ctx(
|
|
81
|
+
[{ id: "v", type: "voice-character", data: { gender: "male", timbre: "deep" } }],
|
|
82
|
+
[audioStyleEdge("v")],
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
expect(out.fields.vocalGender).toBe("male")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("extracts voice-character.gender → fields.vocalGender (female)", () => {
|
|
89
|
+
const out = composeSoundHintFromConnections(
|
|
90
|
+
sunoConsumer({ customMode: true }),
|
|
91
|
+
"suno-generate",
|
|
92
|
+
ctx(
|
|
93
|
+
[{ id: "v", type: "voice-character", data: { gender: "female" } }],
|
|
94
|
+
[audioStyleEdge("v")],
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
expect(out.fields.vocalGender).toBe("female")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("does NOT extract vocalGender for androgynous (Suno's field is binary)", () => {
|
|
101
|
+
const out = composeSoundHintFromConnections(
|
|
102
|
+
sunoConsumer({ customMode: true }),
|
|
103
|
+
"suno-generate",
|
|
104
|
+
ctx(
|
|
105
|
+
[{ id: "v", type: "voice-character", data: { gender: "androgynous", timbre: "warm" } }],
|
|
106
|
+
[audioStyleEdge("v")],
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
expect(out.fields.vocalGender).toBeUndefined()
|
|
110
|
+
// Text still includes the description
|
|
111
|
+
expect(out.text).toContain("warm")
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("composes mixed music + voice nodes for Suno", () => {
|
|
115
|
+
const out = composeSoundHintFromConnections(
|
|
116
|
+
sunoConsumer({ customMode: false }),
|
|
117
|
+
"suno-generate",
|
|
118
|
+
ctx(
|
|
119
|
+
[
|
|
120
|
+
{ id: "g", type: "music-genre", data: { genre: "rock" } },
|
|
121
|
+
{ id: "v", type: "voice-character", data: { gender: "male", timbre: "raspy" } },
|
|
122
|
+
{ id: "d", type: "voice-delivery", data: { emotion: "intense" } },
|
|
123
|
+
],
|
|
124
|
+
[audioStyleEdge("g"), audioStyleEdge("v"), audioStyleEdge("d")],
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
expect(out.text).toMatch(/rock|raspy/)
|
|
128
|
+
expect(out.fields.vocalGender).toBe("male")
|
|
129
|
+
expect(out.warnings).toEqual([])
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Regression: the typed-handle split gave suno-generate three pips
|
|
134
|
+
// (prompt / audio-style / voice). A descriptive voice-character picker dropped
|
|
135
|
+
// onto the "Voice" pip must STILL fold into the output — before, suno had one
|
|
136
|
+
// picker handle and "any picker, any pip" worked. The voice pip's persona
|
|
137
|
+
// producers (suno-voice / voice-design) carry no prompt hint, so they're
|
|
138
|
+
// skipped here and still route to personaId via the input-resolver.
|
|
139
|
+
describe("composeSoundHintFromConnections — Suno Generate, voice pip", () => {
|
|
140
|
+
it("folds a voice-character picker wired to the VOICE handle (not just audio-style)", () => {
|
|
141
|
+
const out = composeSoundHintFromConnections(
|
|
142
|
+
sunoConsumer({ customMode: false }),
|
|
143
|
+
"suno-generate",
|
|
144
|
+
ctx(
|
|
145
|
+
[{ id: "v", type: "voice-character", data: { timbre: "warm", gender: "female", accent: "british-rp" } }],
|
|
146
|
+
[voiceEdge("v")],
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
expect(out.text).toContain("warm")
|
|
150
|
+
expect(out.fields.vocalGender).toBe("female")
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("collects pickers from audio-style AND voice pips together", () => {
|
|
154
|
+
const out = composeSoundHintFromConnections(
|
|
155
|
+
sunoConsumer({ customMode: false }),
|
|
156
|
+
"suno-generate",
|
|
157
|
+
ctx(
|
|
158
|
+
[
|
|
159
|
+
{ id: "g", type: "music-genre", data: { genre: "electronic", subgenre: "synthwave" } },
|
|
160
|
+
{ id: "v", type: "voice-character", data: { timbre: "raspy", gender: "male" } },
|
|
161
|
+
],
|
|
162
|
+
[audioStyleEdge("g"), voiceEdge("v")],
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
expect(out.text).toContain("synthwave")
|
|
166
|
+
expect(out.text).toContain("raspy")
|
|
167
|
+
expect(out.fields.vocalGender).toBe("male")
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("ignores a suno-voice persona on the voice pip (no hint, no crash)", () => {
|
|
171
|
+
const out = composeSoundHintFromConnections(
|
|
172
|
+
sunoConsumer({ customMode: false }),
|
|
173
|
+
"suno-generate",
|
|
174
|
+
ctx(
|
|
175
|
+
[{ id: "p", type: "suno-voice", data: { voiceId: "abc", personaModel: "voice_persona" } }],
|
|
176
|
+
[voiceEdge("p")],
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
expect(out.text).toBe("")
|
|
180
|
+
expect(out.warnings).toEqual([])
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe("composeSoundHintFromConnections — Generate Music (MiniMax)", () => {
|
|
185
|
+
const minimaxConsumer = (data: Record<string, unknown> = { provider: "minimax" }): HintNodeLike => ({
|
|
186
|
+
id: "consumer", type: "generate-music", data,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("populates typed fields when provider=minimax", () => {
|
|
190
|
+
const out = composeSoundHintFromConnections(
|
|
191
|
+
minimaxConsumer(),
|
|
192
|
+
"generate-music",
|
|
193
|
+
ctx(
|
|
194
|
+
[
|
|
195
|
+
{ id: "g", type: "music-genre", data: { genre: "electronic" } },
|
|
196
|
+
{ id: "m", type: "music-mood", data: { emotion: "triumphant" } },
|
|
197
|
+
{ id: "i", type: "instrumentation", data: { vocalPresence: "instrumental" } },
|
|
198
|
+
],
|
|
199
|
+
[audioStyleEdge("g"), audioStyleEdge("m"), audioStyleEdge("i")],
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
expect(out.fields?.genre).toBeTruthy()
|
|
203
|
+
expect(out.fields?.mood).toBeTruthy()
|
|
204
|
+
expect(out.fields?.instrumental).toBe(true)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("falls back to prompt-only for non-minimax providers", () => {
|
|
208
|
+
const out = composeSoundHintFromConnections(
|
|
209
|
+
{ id: "consumer", type: "generate-music", data: { provider: "musicgen" } },
|
|
210
|
+
"generate-music",
|
|
211
|
+
ctx(
|
|
212
|
+
[{ id: "g", type: "music-genre", data: { genre: "electronic" } }],
|
|
213
|
+
[audioStyleEdge("g")],
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
expect(out.fields?.genre).toBeUndefined()
|
|
217
|
+
expect(out.text).toContain("electronic")
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("accepts voice nodes (music with vocals benefits from voice description)", () => {
|
|
221
|
+
const out = composeSoundHintFromConnections(
|
|
222
|
+
minimaxConsumer(),
|
|
223
|
+
"generate-music",
|
|
224
|
+
ctx(
|
|
225
|
+
[{ id: "v", type: "voice-character", data: { gender: "female", timbre: "smooth" } }],
|
|
226
|
+
[audioStyleEdge("v")],
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
expect(out.text).toContain("smooth")
|
|
230
|
+
expect(out.fields.vocalGender).toBe("female")
|
|
231
|
+
expect(out.warnings).toEqual([])
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
describe("composeSoundHintFromConnections — Voice Design", () => {
|
|
236
|
+
const voiceConsumer: HintNodeLike = { id: "consumer", type: "voice-design", data: {} }
|
|
237
|
+
|
|
238
|
+
it("composes voice nodes into voiceDescription", () => {
|
|
239
|
+
const out = composeSoundHintFromConnections(
|
|
240
|
+
voiceConsumer,
|
|
241
|
+
"voice-design",
|
|
242
|
+
ctx(
|
|
243
|
+
[
|
|
244
|
+
{ id: "vc", type: "voice-character", data: { age: "middle-aged", gender: "male", timbre: "warm" } },
|
|
245
|
+
{ id: "vd", type: "voice-delivery", data: { archetype: "documentary-narrator", emotion: "reassuring" } },
|
|
246
|
+
],
|
|
247
|
+
[audioStyleEdge("vc"), audioStyleEdge("vd")],
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
expect(out.text).toContain("warm")
|
|
251
|
+
expect(out.text).toContain("documentary narrator")
|
|
252
|
+
expect(out.fields?.voiceDescription).toBe(out.text)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("warns on music nodes", () => {
|
|
256
|
+
const out = composeSoundHintFromConnections(
|
|
257
|
+
voiceConsumer,
|
|
258
|
+
"voice-design",
|
|
259
|
+
ctx(
|
|
260
|
+
[{ id: "g", type: "music-genre", data: { genre: "electronic" } }],
|
|
261
|
+
[audioStyleEdge("g")],
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
expect(out.warnings.length).toBeGreaterThan(0)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe("composeSoundHintFromConnections — Voice Remix", () => {
|
|
269
|
+
const remixConsumer: HintNodeLike = { id: "consumer", type: "voice-remix", data: {} }
|
|
270
|
+
|
|
271
|
+
it("composes voice nodes into voiceDescription (mirrors voice-design)", () => {
|
|
272
|
+
const out = composeSoundHintFromConnections(
|
|
273
|
+
remixConsumer,
|
|
274
|
+
"voice-remix",
|
|
275
|
+
ctx(
|
|
276
|
+
[
|
|
277
|
+
{ id: "vc", type: "voice-character", data: { age: "young-adult", gender: "female", timbre: "bright" } },
|
|
278
|
+
{ id: "vd", type: "voice-delivery", data: { archetype: "friendly-host" } },
|
|
279
|
+
],
|
|
280
|
+
[audioStyleEdge("vc"), audioStyleEdge("vd")],
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
expect(out.text).toContain("bright")
|
|
284
|
+
expect(out.fields.voiceDescription).toBe(out.text)
|
|
285
|
+
expect(out.warnings).toEqual([])
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("warns on music nodes (Voice Remix is voice-only)", () => {
|
|
289
|
+
const out = composeSoundHintFromConnections(
|
|
290
|
+
remixConsumer,
|
|
291
|
+
"voice-remix",
|
|
292
|
+
ctx(
|
|
293
|
+
[{ id: "g", type: "music-genre", data: { genre: "electronic" } }],
|
|
294
|
+
[audioStyleEdge("g")],
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
expect(out.text).toBe("")
|
|
298
|
+
expect(out.warnings.length).toBeGreaterThan(0)
|
|
299
|
+
expect(out.warnings[0]).toContain("Voice Remix")
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe("composeSoundHintFromConnections — minimax multi-emotion", () => {
|
|
304
|
+
const minimaxConsumer = (): HintNodeLike => ({
|
|
305
|
+
id: "consumer", type: "generate-music", data: { provider: "minimax" },
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it("joins multiple emotions into fields.mood for minimax", () => {
|
|
309
|
+
const out = composeSoundHintFromConnections(
|
|
310
|
+
minimaxConsumer(),
|
|
311
|
+
"generate-music",
|
|
312
|
+
ctx(
|
|
313
|
+
[{ id: "m", type: "music-mood", data: { emotion: ["bittersweet", "mysterious"] } }],
|
|
314
|
+
[audioStyleEdge("m")],
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
expect(out.fields.mood).toContain("bittersweet")
|
|
318
|
+
expect(out.fields.mood).toContain("mysterious")
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it("handles single-string emotion for minimax (back-compat)", () => {
|
|
322
|
+
const out = composeSoundHintFromConnections(
|
|
323
|
+
minimaxConsumer(),
|
|
324
|
+
"generate-music",
|
|
325
|
+
ctx(
|
|
326
|
+
[{ id: "m", type: "music-mood", data: { emotion: "ethereal" } }],
|
|
327
|
+
[audioStyleEdge("m")],
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
expect(out.fields.mood).toContain("ethereal")
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe("composeSoundHintFromConnections — Text to Audio", () => {
|
|
335
|
+
it("accepts music nodes, warns on voice nodes", () => {
|
|
336
|
+
const out = composeSoundHintFromConnections(
|
|
337
|
+
{ id: "consumer", type: "text-to-audio", data: {} },
|
|
338
|
+
"text-to-audio",
|
|
339
|
+
ctx(
|
|
340
|
+
[
|
|
341
|
+
{ id: "g", type: "music-genre", data: { genre: "electronic" } },
|
|
342
|
+
{ id: "v", type: "voice-character", data: { timbre: "warm" } },
|
|
343
|
+
],
|
|
344
|
+
[audioStyleEdge("g"), audioStyleEdge("v")],
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
expect(out.text).toContain("electronic")
|
|
348
|
+
expect(out.warnings.length).toBeGreaterThan(0)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { STYLE_PRESETS, getStylePreset } from "../style-presets.js"
|
|
3
|
+
|
|
4
|
+
describe("STYLE_PRESETS", () => {
|
|
5
|
+
it("has a non-trivial catalog", () => {
|
|
6
|
+
expect(STYLE_PRESETS.length).toBeGreaterThanOrEqual(8)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("every preset has a unique id", () => {
|
|
10
|
+
const ids = STYLE_PRESETS.map((s) => s.id)
|
|
11
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("every preset carries usable conditioning + a label + a swatch", () => {
|
|
15
|
+
for (const s of STYLE_PRESETS) {
|
|
16
|
+
expect(s.label.length, `${s.id} label`).toBeGreaterThan(0)
|
|
17
|
+
expect(s.swatch, `${s.id} swatch`).toMatch(/gradient|#/)
|
|
18
|
+
// visualStyle is the load-bearing directive that conditions generation.
|
|
19
|
+
expect(s.directives.visualStyle?.length ?? 0, `${s.id} visualStyle`).toBeGreaterThan(0)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe("getStylePreset", () => {
|
|
25
|
+
it("resolves a known id", () => {
|
|
26
|
+
expect(getStylePreset("cinematic")?.label).toBe("Cinematic Photography")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("returns undefined for the Auto / empty / unknown case", () => {
|
|
30
|
+
expect(getStylePreset(undefined)).toBeUndefined()
|
|
31
|
+
expect(getStylePreset("")).toBeUndefined()
|
|
32
|
+
expect(getStylePreset("does-not-exist")).toBeUndefined()
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { buildTemporalHints, TEMPORAL_FIELD_BY_CATEGORY, TEMPORAL_CATEGORY_ORDER } from "../temporal.js"
|
|
3
|
+
|
|
4
|
+
describe("buildTemporalHints", () => {
|
|
5
|
+
it("returns empty when nothing is set", () => {
|
|
6
|
+
const hints = buildTemporalHints({})
|
|
7
|
+
expect(hints).toEqual([])
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("emits one hint per set per-category field, in canonical category order", () => {
|
|
11
|
+
const hints = buildTemporalHints({
|
|
12
|
+
temporalSpeed: "slow-motion",
|
|
13
|
+
temporalFreeze: "bullet-time",
|
|
14
|
+
temporalDirection: "reverse",
|
|
15
|
+
temporalShutter: "long-exposure",
|
|
16
|
+
})
|
|
17
|
+
expect(hints.length).toBe(4)
|
|
18
|
+
// Canonical order: speed, freeze, direction, shutter
|
|
19
|
+
expect(hints[0]).toMatch(/slow motion/i)
|
|
20
|
+
expect(hints[1]).toMatch(/bullet time/i)
|
|
21
|
+
expect(hints[2]).toMatch(/reverse/i)
|
|
22
|
+
expect(hints[3]).toMatch(/long exposure/i)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("ignores the legacy temporal field — multi-category is the sole shape", () => {
|
|
26
|
+
// Legacy compat layer was removed: the legacy `temporal` field is no
|
|
27
|
+
// longer read; only per-category fields are considered.
|
|
28
|
+
const hints = buildTemporalHints({ temporal: "slow-motion" } as Record<string, unknown>)
|
|
29
|
+
expect(hints).toEqual([])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("ignores empty-string and non-string values", () => {
|
|
33
|
+
const hints = buildTemporalHints({
|
|
34
|
+
temporalSpeed: "",
|
|
35
|
+
temporalFreeze: undefined,
|
|
36
|
+
temporalDirection: null as unknown as string,
|
|
37
|
+
temporalShutter: "motion-blur",
|
|
38
|
+
})
|
|
39
|
+
expect(hints.length).toBe(1)
|
|
40
|
+
expect(hints[0]).toMatch(/motion blur/i)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("TEMPORAL_FIELD_BY_CATEGORY covers all categories in order", () => {
|
|
44
|
+
for (const cat of TEMPORAL_CATEGORY_ORDER) {
|
|
45
|
+
expect(TEMPORAL_FIELD_BY_CATEGORY[cat]).toBeDefined()
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("TEMPORAL_FIELD_BY_CATEGORY maps each category to the expected field name", () => {
|
|
50
|
+
expect(TEMPORAL_FIELD_BY_CATEGORY.speed).toBe("temporalSpeed")
|
|
51
|
+
expect(TEMPORAL_FIELD_BY_CATEGORY.freeze).toBe("temporalFreeze")
|
|
52
|
+
expect(TEMPORAL_FIELD_BY_CATEGORY.direction).toBe("temporalDirection")
|
|
53
|
+
expect(TEMPORAL_FIELD_BY_CATEGORY.shutter).toBe("temporalShutter")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("returns empty when an unknown temporal id is given (no hint string)", () => {
|
|
57
|
+
const hints = buildTemporalHints({ temporalSpeed: "this-is-not-a-real-id" })
|
|
58
|
+
expect(hints).toEqual([])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("aggregates all 4 categories with the correct prompt-hint substrings", () => {
|
|
62
|
+
const hints = buildTemporalHints({
|
|
63
|
+
temporalSpeed: "time-lapse",
|
|
64
|
+
temporalFreeze: "frozen-subject",
|
|
65
|
+
temporalDirection: "loop-boomerang",
|
|
66
|
+
temporalShutter: "stop-motion",
|
|
67
|
+
})
|
|
68
|
+
expect(hints.length).toBe(4)
|
|
69
|
+
expect(hints[0]).toMatch(/time-lapse/i)
|
|
70
|
+
expect(hints[1]).toMatch(/frozen subject/i)
|
|
71
|
+
expect(hints[2]).toMatch(/boomerang/i)
|
|
72
|
+
expect(hints[3]).toMatch(/stop-motion/i)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { TRANSITIONS, TRANSITION_IDS, TRANSITION_CATEGORY_ORDER, TRANSITION_CATEGORY_LABELS, composeTransitionHintFromConnections, getTransition, getTransitionLabel, getTransitionPromptHint } from "../transitions.js"
|
|
3
|
+
|
|
4
|
+
describe("transitions catalog", () => {
|
|
5
|
+
it("ships 76 unique entries", () => {
|
|
6
|
+
expect(TRANSITIONS).toHaveLength(76)
|
|
7
|
+
expect(new Set(TRANSITION_IDS).size).toBe(76)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("every entry has a non-empty id, label, and description", () => {
|
|
11
|
+
for (const t of TRANSITIONS) {
|
|
12
|
+
expect(t.id).toMatch(/^[a-z][a-z0-9-]*$/)
|
|
13
|
+
expect(t.label.length).toBeGreaterThan(0)
|
|
14
|
+
expect(t.description.length).toBeGreaterThan(0)
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("category counts match the spec §3.3 table", () => {
|
|
19
|
+
const counts: Record<string, number> = {}
|
|
20
|
+
for (const t of TRANSITIONS) {
|
|
21
|
+
counts[t.category] = (counts[t.category] ?? 0) + 1
|
|
22
|
+
}
|
|
23
|
+
expect(counts).toEqual({
|
|
24
|
+
standard: 11, time: 8, element: 14, morph: 9,
|
|
25
|
+
portal: 10, physics: 9, light: 8, glitch: 7,
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("category order covers every category and matches labels", () => {
|
|
30
|
+
expect(new Set(TRANSITION_CATEGORY_ORDER)).toEqual(
|
|
31
|
+
new Set(Object.keys(TRANSITION_CATEGORY_LABELS)),
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("auto has empty promptHint by design", () => {
|
|
36
|
+
expect(getTransitionPromptHint("auto")).toBe("")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("none has a hard-cut promptHint (not empty)", () => {
|
|
40
|
+
expect(getTransitionPromptHint("none")).toBe(
|
|
41
|
+
"no transition, hard cut, instantaneous switch from first shot to second shot",
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe("getTransition / getTransitionLabel / getTransitionPromptHint", () => {
|
|
47
|
+
it("returns the entry for a known id", () => {
|
|
48
|
+
const t = getTransition("cross-dissolve")
|
|
49
|
+
expect(t?.label).toBe("Cross-Dissolve")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("returns undefined for nullish + unknown ids", () => {
|
|
53
|
+
expect(getTransition(undefined)).toBeUndefined()
|
|
54
|
+
expect(getTransition(null)).toBeUndefined()
|
|
55
|
+
expect(getTransition("nonexistent")).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("getTransitionLabel falls back to title-cased id when unknown", () => {
|
|
59
|
+
expect(getTransitionLabel("nonexistent-id")).toBe("Nonexistent Id")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("getTransitionPromptHint returns the hint or empty string", () => {
|
|
63
|
+
expect(getTransitionPromptHint("cross-dissolve")).toContain("cross-dissolve")
|
|
64
|
+
expect(getTransitionPromptHint("nonexistent")).toBe("")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("getTransitionLabel returns empty string for null/undefined", () => {
|
|
68
|
+
expect(getTransitionLabel(null)).toBe("")
|
|
69
|
+
expect(getTransitionLabel(undefined)).toBe("")
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("composeTransitionHintFromConnections — single-pick", () => {
|
|
74
|
+
it("returns the bare hint when no connections + no timing", () => {
|
|
75
|
+
const r = composeTransitionHintFromConnections("cross-dissolve", [], [])
|
|
76
|
+
expect(r).toBe(getTransitionPromptHint("cross-dissolve"))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("returns empty when id is undefined / 'auto' / unknown", () => {
|
|
80
|
+
expect(composeTransitionHintFromConnections(undefined, ["A"], ["B"])).toBe("")
|
|
81
|
+
expect(composeTransitionHintFromConnections("unknown-id", ["A"], ["B"])).toBe("")
|
|
82
|
+
expect(composeTransitionHintFromConnections("auto", ["A"], ["B"])).toBe("")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("treats null like undefined (falsy guard)", () => {
|
|
86
|
+
// Persisted workflow JSON can deliver null at runtime even though the signature is undefined-only.
|
|
87
|
+
expect(composeTransitionHintFromConnections(null as unknown as undefined, ["A"], ["B"])).toBe("")
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("appends start clause", () => {
|
|
91
|
+
const r = composeTransitionHintFromConnections("cross-dissolve", ["morning"], [])
|
|
92
|
+
expect(r).toMatch(/, starting from morning$/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("appends end clause", () => {
|
|
96
|
+
const r = composeTransitionHintFromConnections("cross-dissolve", [], ["night"])
|
|
97
|
+
expect(r).toMatch(/, ending at night$/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("joins multiple start hints with ' and '", () => {
|
|
101
|
+
const r = composeTransitionHintFromConnections(
|
|
102
|
+
"cross-dissolve",
|
|
103
|
+
["A", "C"],
|
|
104
|
+
["B"],
|
|
105
|
+
)
|
|
106
|
+
expect(r).toMatch(/, starting from A and C, ending at B$/)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe("composeTransitionHintFromConnections — timing clauses", () => {
|
|
111
|
+
it("appends position clause when non-auto", () => {
|
|
112
|
+
const r = composeTransitionHintFromConnections("cross-dissolve", [], [], { position: "end" })
|
|
113
|
+
expect(r).toContain("the transition occurs at the end of the clip")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("appends duration clause when non-auto", () => {
|
|
117
|
+
const r = composeTransitionHintFromConnections("cross-dissolve", [], [], { duration: "medium" })
|
|
118
|
+
expect(r).toContain("lasting approximately 2 seconds")
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("appends intensity clause when non-auto", () => {
|
|
122
|
+
const r = composeTransitionHintFromConnections("cross-dissolve", [], [], { intensity: "dynamic" })
|
|
123
|
+
expect(r).toContain("with dynamic energy and assertive flourish")
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("omits clause when value is 'auto'", () => {
|
|
127
|
+
const r = composeTransitionHintFromConnections("cross-dissolve", [], [], { position: "auto" })
|
|
128
|
+
expect(r).not.toContain("the transition occurs")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("appends all three timing clauses in position/duration/intensity order", () => {
|
|
132
|
+
const r = composeTransitionHintFromConnections(
|
|
133
|
+
"cross-dissolve",
|
|
134
|
+
[],
|
|
135
|
+
[],
|
|
136
|
+
{ position: "end", duration: "medium", intensity: "dynamic" },
|
|
137
|
+
)
|
|
138
|
+
const idxPos = r.indexOf("the transition occurs")
|
|
139
|
+
const idxDur = r.indexOf("lasting approximately")
|
|
140
|
+
const idxInt = r.indexOf("with dynamic energy")
|
|
141
|
+
expect(idxPos).toBeGreaterThan(0)
|
|
142
|
+
expect(idxDur).toBeGreaterThan(idxPos)
|
|
143
|
+
expect(idxInt).toBeGreaterThan(idxDur)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("composes timing + start + end together in spec §4.2 order", () => {
|
|
147
|
+
const r = composeTransitionHintFromConnections(
|
|
148
|
+
"fast-forward-day-night",
|
|
149
|
+
["warm golden morning light"],
|
|
150
|
+
["deep blue moonlit night"],
|
|
151
|
+
{ position: "end", duration: "medium", intensity: "dynamic" },
|
|
152
|
+
)
|
|
153
|
+
expect(r).toContain("the transition occurs at the end of the clip")
|
|
154
|
+
expect(r).toContain("lasting approximately 2 seconds")
|
|
155
|
+
expect(r).toContain("with dynamic energy and assertive flourish")
|
|
156
|
+
expect(r).toContain("starting from warm golden morning light")
|
|
157
|
+
expect(r).toMatch(/, ending at deep blue moonlit night$/)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe("composeTransitionHintFromConnections — multi-pick", () => {
|
|
162
|
+
it("scalar form is identical to single-element array", () => {
|
|
163
|
+
const scalar = composeTransitionHintFromConnections("smash-cut", [], [])
|
|
164
|
+
const array = composeTransitionHintFromConnections(["smash-cut"], [], [])
|
|
165
|
+
expect(scalar).toBe(array)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("joins two base hints with ', and '", () => {
|
|
169
|
+
const r = composeTransitionHintFromConnections(["smash-cut", "white-flash"], [], [])
|
|
170
|
+
const a = getTransitionPromptHint("smash-cut")
|
|
171
|
+
const b = getTransitionPromptHint("white-flash")
|
|
172
|
+
expect(r).toBe(`${a}, and ${b}`)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("dedupes duplicate ids", () => {
|
|
176
|
+
const r1 = composeTransitionHintFromConnections(["smash-cut", "smash-cut"], [], [])
|
|
177
|
+
const r2 = composeTransitionHintFromConnections("smash-cut", [], [])
|
|
178
|
+
expect(r1).toBe(r2)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("caps at 2 ids — extra ids dropped", () => {
|
|
182
|
+
const a = getTransitionPromptHint("smash-cut")
|
|
183
|
+
const b = getTransitionPromptHint("white-flash")
|
|
184
|
+
const r = composeTransitionHintFromConnections(
|
|
185
|
+
["smash-cut", "white-flash", "fade-to-black", "wipe"],
|
|
186
|
+
[],
|
|
187
|
+
[],
|
|
188
|
+
)
|
|
189
|
+
expect(r).toBe(`${a}, and ${b}`)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("returns '' on empty array", () => {
|
|
193
|
+
expect(composeTransitionHintFromConnections([], [], [])).toBe("")
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("filters out 'auto' (empty hint) entries silently", () => {
|
|
197
|
+
const onlyFade = composeTransitionHintFromConnections("fade-to-black", [], [])
|
|
198
|
+
const withAuto = composeTransitionHintFromConnections(["auto", "fade-to-black"], [], [])
|
|
199
|
+
expect(withAuto).toBe(onlyFade)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("applies timing + start/end clauses once at the outer layer (not per-id)", () => {
|
|
203
|
+
const r = composeTransitionHintFromConnections(
|
|
204
|
+
["smash-cut", "white-flash"],
|
|
205
|
+
["morning"],
|
|
206
|
+
["night"],
|
|
207
|
+
{ intensity: "dynamic" },
|
|
208
|
+
)
|
|
209
|
+
expect((r.match(/with dynamic energy/g) ?? []).length).toBe(1)
|
|
210
|
+
expect(r.indexOf("with dynamic energy")).toBeGreaterThan(r.indexOf(", and "))
|
|
211
|
+
expect(r.indexOf("starting from morning")).toBeGreaterThan(r.indexOf("with dynamic energy"))
|
|
212
|
+
})
|
|
213
|
+
})
|