@nodaro/prompts 1.17.2 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.17.2",
3
+ "version": "1.19.0",
4
4
  "description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -20,7 +20,7 @@
20
20
  "test": "vitest run"
21
21
  },
22
22
  "dependencies": {
23
- "@nodaro/shared": "^3.0.0"
23
+ "@nodaro/shared": "^3.9.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "tsup": "^8.5.0",
@@ -181,17 +181,30 @@ describe("assembleSunoInput — persona spread", () => {
181
181
 
182
182
  describe("assembleSunoInput — duration pass-through", () => {
183
183
  it("carries data.duration onto the result verbatim (the provider client gates the send)", () => {
184
+ const r = assembleSunoInput({
185
+ node: node({ customMode: true, style: "pop", model: "V6", duration: 120 }),
186
+ graph: emptyGraph,
187
+ userPrompt: "song",
188
+ })
189
+ expect(r.duration).toBe(120)
190
+ })
191
+
192
+ // The assembler is a FIELD CARRIER, not the duration gate: only the provider
193
+ // client consults `sunoModelHonoursDuration` (V6 family), so a legacy model
194
+ // still carries `duration` through this layer unchanged.
195
+ it("carries data.duration for a LEGACY model too — the gate lives in the provider client", () => {
184
196
  const r = assembleSunoInput({
185
197
  node: node({ customMode: true, style: "pop", model: "V5_5", duration: 120 }),
186
198
  graph: emptyGraph,
187
199
  userPrompt: "song",
188
200
  })
189
201
  expect(r.duration).toBe(120)
202
+ expect(r.model).toBe("V5_5")
190
203
  })
191
204
 
192
205
  it("no data.duration → undefined", () => {
193
206
  const r = assembleSunoInput({
194
- node: node({ model: "V5_5" }),
207
+ node: node({ model: "V6" }),
195
208
  graph: emptyGraph,
196
209
  userPrompt: "song",
197
210
  })
@@ -381,7 +381,7 @@
381
381
  }
382
382
  },
383
383
  "ctx": null,
384
- "expected": "camera arcs to the left around the subject"
384
+ "expected": "slow camera arc around the subject through a partial arc, the background sliding to the left across the frame"
385
385
  },
386
386
  {
387
387
  "key": "camera-motion/id:auto",
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { joinHintFragments } from "../hint-join.js"
3
+ import { composeCameraMotionHintFromConnections, getCameraMotionPromptHint } from "../camera-motions.js"
4
+ import { getParameterPromptHint } from "../parameter-prompt-hint.js"
5
+
6
+ /**
7
+ * The tested camera-motion injections are multi-sentence paragraphs ending in
8
+ * a period. Every composer used to glue fragments with `", "`, which produced
9
+ * `"...no pull-out., beginning with ..."`. The joiner keeps the comma between
10
+ * clause-style fragments (byte-identical to before) and uses a space after a
11
+ * fragment that already closes a sentence.
12
+ */
13
+ describe("joinHintFragments", () => {
14
+ it("keeps the historical comma join for clause-style fragments", () => {
15
+ expect(joinHintFragments(["slow pan left", "beginning with wide shot"])).toBe(
16
+ "slow pan left, beginning with wide shot",
17
+ )
18
+ })
19
+
20
+ it("uses a space after a fragment that already ends a sentence", () => {
21
+ expect(joinHintFragments(["The camera pans left.", "beginning with wide shot"])).toBe(
22
+ "The camera pans left. beginning with wide shot",
23
+ )
24
+ expect(joinHintFragments(["Hold!", "then cut", "Done?", "fin"])).toBe("Hold! then cut, Done? fin")
25
+ })
26
+
27
+ it("skips empty fragments and never adds or strips punctuation", () => {
28
+ expect(joinHintFragments(["", "The camera pans left.", ""])).toBe("The camera pans left.")
29
+ expect(joinHintFragments([])).toBe("")
30
+ })
31
+ })
32
+
33
+ describe("sentence-style camera-motion hints compose cleanly", () => {
34
+ it("never emits a period directly followed by a comma", () => {
35
+ const composed = composeCameraMotionHintFromConnections("dolly-in", ["wide shot"], ["close-up"])
36
+ expect(composed).not.toContain(".,")
37
+ expect(composed.startsWith(getCameraMotionPromptHint("dolly-in"))).toBe(true)
38
+ expect(composed).toContain("beginning with wide shot, ending with close-up")
39
+ })
40
+
41
+ it("wraps preText/postText around a sentence-style hint without a stray comma", () => {
42
+ const out = getParameterPromptHint({
43
+ id: "n1",
44
+ type: "camera-motion",
45
+ data: { cameraMotion: "tilt-up", preText: "shot on location", postText: "golden hour" },
46
+ })
47
+ expect(out).not.toContain(".,")
48
+ expect(out.startsWith("shot on location, The camera")).toBe(true)
49
+ expect(out.endsWith("No zoom, no push-in. golden hour")).toBe(true)
50
+ })
51
+
52
+ it("keeps a clause-style hint byte-identical to the pre-joiner output", () => {
53
+ expect(composeCameraMotionHintFromConnections("static", ["wide shot"], [])).toBe(
54
+ "locked off static camera, no camera movement, beginning with wide shot",
55
+ )
56
+ })
57
+ })
@@ -16,10 +16,10 @@ describe("nodeSupportsPromptAffixes", () => {
16
16
  expect(nodeSupportsPromptAffixes("not-a-node")).toBe(false)
17
17
  expect(nodeSupportsPromptAffixes(undefined)).toBe(false)
18
18
  })
19
- it("PROMPT_AFFIX_NODE_TYPES is exactly the registry minus opt-outs (39 types)", () => {
19
+ it("PROMPT_AFFIX_NODE_TYPES is exactly the registry minus opt-outs (40 types)", () => {
20
20
  const expected = Object.entries(NODE_PROMPT_FIELDS).filter(([, s]) => s.affixes !== false).map(([t]) => t)
21
21
  expect([...PROMPT_AFFIX_NODE_TYPES].sort()).toEqual(expected.sort())
22
- expect(PROMPT_AFFIX_NODE_TYPES.size).toBe(39)
22
+ expect(PROMPT_AFFIX_NODE_TYPES.size).toBe(40)
23
23
  })
24
24
  it("getPromptFields still resolves", () => expect(getPromptFields("generate-image")?.prompt).toBe("prompt"))
25
25
  })
@@ -28,6 +28,7 @@ describe("promptAffixCoreField — the data key the RUN wraps with pre/post text
28
28
  it("most nodes: the editor's primary prompt field", () => {
29
29
  expect(promptAffixCoreField("generate-image")).toBe("prompt")
30
30
  expect(promptAffixCoreField("llm-chat")).toBe("userInput")
31
+ expect(promptAffixCoreField("pro-3d-render")).toBe("scenePrompt")
31
32
  expect(promptAffixCoreField("generate-3d-scene")).toBe("scenePrompt")
32
33
  expect(promptAffixCoreField("edit-3d-scene")).toBe("editPrompt")
33
34
  expect(promptAffixCoreField("text-to-speech")).toBe("directText")
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Scene3D layout-reference doctrine — the wording the 2026-09-10 greybox
3
+ * experiment's Seedance A/B rerun quotes verbatim, pinned byte-for-byte, plus
4
+ * the still/clip/cuts variants, the re-run idempotence key and the rule-2
5
+ * warning builder.
6
+ */
7
+ import { describe, it, expect } from "vitest"
8
+ import {
9
+ buildScene3DLayoutScopingLine,
10
+ buildScene3DUnreferencedFiguresWarning,
11
+ hasScene3DLayoutScopingLine,
12
+ renderScene3DLayoutScopingLine,
13
+ SCENE3D_FIGURE_REFERENCE_RULE,
14
+ SCENE3D_LAYOUT_REFERENCE_SCOPING_FIXTURE,
15
+ SCENE3D_LAYOUT_REFERENCE_SCOPING_LINE,
16
+ SCENE3D_LAYOUT_SCOPING_MARKER,
17
+ SCENE3D_UNREFERENCED_FIGURES_WARNING_CODE,
18
+ } from "../scene3d-reference-doctrine.js"
19
+ import { renderReferenceCaptionLines } from "../described-references.js"
20
+
21
+ const CLIP =
22
+ "LAYOUT reference only — match its subject positions and blocking, its foreground occlusion, its framing, its camera angle, its camera motion and its timing. " +
23
+ "Ignore its untextured grey clay placeholder look, its flat placeholder colours, its materials, its lighting and its empty background; none of that is the target look. " +
24
+ "Take the look from the prompt and from the other references"
25
+
26
+ const STILL =
27
+ "LAYOUT reference only — match its subject positions and blocking, its foreground occlusion, its framing and its camera angle. " +
28
+ "Ignore its untextured grey clay placeholder look, its flat placeholder colours, its materials, its lighting and its empty background; none of that is the target look. " +
29
+ "Take the look from the prompt and from the other references"
30
+
31
+ describe("SCENE3D_LAYOUT_REFERENCE_SCOPING_LINE — the fixture the A/B rerun quotes", () => {
32
+ it("is the clip caption: what the reference is for, then what to ignore, plainly", () => {
33
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_LINE).toBe(CLIP)
34
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_FIXTURE.clip).toBe(CLIP)
35
+ })
36
+
37
+ it("is a caption, not a line: no leading binding, no trailing full stop (the renderer adds both)", () => {
38
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_LINE.startsWith(SCENE3D_LAYOUT_SCOPING_MARKER)).toBe(true)
39
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_LINE.endsWith(".")).toBe(false)
40
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_LINE).not.toContain("@video")
41
+ })
42
+
43
+ it("renders on its seat exactly as the platform's rail-caption renderer renders it", () => {
44
+ const platform = renderReferenceCaptionLines([SCENE3D_LAYOUT_REFERENCE_SCOPING_LINE], undefined, { video: 1, audio: 0 })
45
+ expect(platform).toEqual([SCENE3D_LAYOUT_REFERENCE_SCOPING_FIXTURE.clipRendered])
46
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_FIXTURE.clipRendered).toBe(`@video_1: ${CLIP}.`)
47
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_FIXTURE.stillRendered).toBe(`@image_1: ${STILL}.`)
48
+ })
49
+
50
+ it("still: drops the motion, timing and cut clauses a single frame cannot carry", () => {
51
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_FIXTURE.still).toBe(STILL)
52
+ // A still with clip-only facts still reads as a still.
53
+ expect(buildScene3DLayoutScopingLine({ carries: "still", shots: 4, includesCameraMotion: true })).toBe(STILL)
54
+ })
55
+
56
+ it("clip with several shots names the cut points; one shot is not a cut", () => {
57
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_FIXTURE.clipWithCuts).toContain(
58
+ "its camera angle, its camera motion, its 4 shots and where they cut and its timing.",
59
+ )
60
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_FIXTURE.clip).not.toContain("where they cut")
61
+ })
62
+
63
+ it("clip without a camera move keeps timing but claims no motion", () => {
64
+ const line = buildScene3DLayoutScopingLine({ carries: "clip", includesCameraMotion: false })
65
+ expect(line).not.toContain("camera motion")
66
+ expect(line).toContain("its camera angle and its timing.")
67
+ })
68
+
69
+ it("names what to ignore explicitly — every property of the clay look — and never the target look", () => {
70
+ for (const word of ["grey clay placeholder look", "placeholder colours", "materials", "lighting", "empty background"]) {
71
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_LINE).toContain(word)
72
+ }
73
+ expect(SCENE3D_LAYOUT_REFERENCE_SCOPING_LINE.toLowerCase()).not.toContain("photoreal")
74
+ })
75
+
76
+ it("renderScene3DLayoutScopingLine trims the binding", () => {
77
+ expect(renderScene3DLayoutScopingLine(" @image_3 ", { carries: "still" }).startsWith("@image_3: LAYOUT")).toBe(true)
78
+ })
79
+ })
80
+
81
+ describe("hasScene3DLayoutScopingLine — the re-run idempotence key", () => {
82
+ it("recognises the platform's rendered line, per binding", () => {
83
+ const prompt = `A brief.\n${SCENE3D_LAYOUT_REFERENCE_SCOPING_FIXTURE.clipRendered}`
84
+ expect(hasScene3DLayoutScopingLine(prompt, "@video_1")).toBe(true)
85
+ expect(hasScene3DLayoutScopingLine(prompt, "@video_2")).toBe(false)
86
+ expect(hasScene3DLayoutScopingLine(prompt, "@video_10")).toBe(false)
87
+ })
88
+
89
+ it("recognises a hand-typed sentence and an older wording, so a pasted line is not doubled", () => {
90
+ expect(hasScene3DLayoutScopingLine("@video_1 is a LAYOUT reference only: match its seating.", "@video_1")).toBe(true)
91
+ expect(hasScene3DLayoutScopingLine("@image_2 is an LAYOUT reference only", "@image_2")).toBe(true)
92
+ expect(hasScene3DLayoutScopingLine("Two people at a table. @video_1 is a great clip.", "@video_1")).toBe(false)
93
+ })
94
+
95
+ it("is false on nothing", () => {
96
+ expect(hasScene3DLayoutScopingLine(undefined, "@video_1")).toBe(false)
97
+ expect(hasScene3DLayoutScopingLine("", "@video_1")).toBe(false)
98
+ })
99
+ })
100
+
101
+ describe("buildScene3DUnreferencedFiguresWarning — rule 2 as a warning, never a block", () => {
102
+ it("warns when figures outnumber character references", () => {
103
+ const w = buildScene3DUnreferencedFiguresWarning({ figureCount: 6, characterReferenceCount: 1 })
104
+ expect(w).toBeDefined()
105
+ expect(w?.code).toBe(SCENE3D_UNREFERENCED_FIGURES_WARNING_CODE)
106
+ expect(w?.missing).toBe(5)
107
+ expect(w?.message).toBe(
108
+ "The layout reference shows 6 figures but 1 character reference is attached. " +
109
+ "A figure without its own character reference takes the clay look of the layout reference. " +
110
+ "Attach one character reference per figure (5 more).",
111
+ )
112
+ })
113
+
114
+ it("states the budget when the image-reference cap is known — a clip rides the video rail", () => {
115
+ const fits = buildScene3DUnreferencedFiguresWarning({ figureCount: 6, characterReferenceCount: 0, imageReferenceCap: 9 })
116
+ expect(fits?.message).toContain("this model takes 9 image references, which leaves 3 for a location or style plate.")
117
+ const tight = buildScene3DUnreferencedFiguresWarning({ figureCount: 8, characterReferenceCount: 0, imageReferenceCap: 9 })
118
+ expect(tight?.message).toContain("does not fit — reference the figures that matter most first.")
119
+ })
120
+
121
+ it("counts the seat a still layout reference occupies against the same budget", () => {
122
+ const still = buildScene3DUnreferencedFiguresWarning({
123
+ figureCount: 6,
124
+ characterReferenceCount: 0,
125
+ imageReferenceCap: 9,
126
+ layoutReferenceImageSeats: 1,
127
+ })
128
+ expect(still?.message).toContain("leaves 2 for a location or style plate.")
129
+ })
130
+
131
+ it("stays silent when every figure has a reference, when there are no figures, or when the count is unknown", () => {
132
+ expect(buildScene3DUnreferencedFiguresWarning({ figureCount: 2, characterReferenceCount: 2 })).toBeUndefined()
133
+ expect(buildScene3DUnreferencedFiguresWarning({ figureCount: 2, characterReferenceCount: 5 })).toBeUndefined()
134
+ expect(buildScene3DUnreferencedFiguresWarning({ figureCount: 0, characterReferenceCount: 0 })).toBeUndefined()
135
+ expect(buildScene3DUnreferencedFiguresWarning({ figureCount: undefined, characterReferenceCount: 0 })).toBeUndefined()
136
+ })
137
+
138
+ it("states the rule in user terms", () => {
139
+ expect(SCENE3D_FIGURE_REFERENCE_RULE).toContain("its own character reference")
140
+ expect(SCENE3D_FIGURE_REFERENCE_RULE).toContain("two reference slots free")
141
+ })
142
+ })
@@ -60,7 +60,7 @@ describe("composeSoundHintFromConnections — Suno Generate", () => {
60
60
  expect(out.warnings).toEqual([])
61
61
  })
62
62
 
63
- it("accepts voice-character nodes (Suno V5 supports vocal description)", () => {
63
+ it("accepts voice-character nodes (Suno V6 supports vocal description)", () => {
64
64
  const out = composeSoundHintFromConnections(
65
65
  sunoConsumer({ customMode: true }),
66
66
  "suno-generate",
@@ -87,8 +87,9 @@ export interface AssembleSunoResult {
87
87
  /**
88
88
  * Requested song length in seconds (KIE: 10–360). Passed through from
89
89
  * `data.duration` unconditionally — the provider client is the single
90
- * gate that only sends it when customMode && model V5_5 (KIE ignores it
91
- * elsewhere), so the assembler stays a faithful field carrier.
90
+ * gate that only sends it when `customMode && sunoModelHonoursDuration(model)`
91
+ * (the V6 family — V6 / V6_MINI / V6_WILD; KIE ignores it on every earlier
92
+ * version), so the assembler stays a faithful field carrier.
92
93
  */
93
94
  duration?: number
94
95
  personaId?: string