@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/dist/index.cjs +224 -80
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +229 -4
- package/dist/index.d.ts +229 -4
- package/dist/index.js +209 -81
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-suno-input.test.ts +14 -1
- package/src/__tests__/fixtures/parameter-hint-golden.json +1 -1
- package/src/__tests__/hint-join.test.ts +57 -0
- package/src/__tests__/node-prompt-fields.test.ts +3 -2
- package/src/__tests__/scene3d-reference-doctrine.test.ts +142 -0
- package/src/__tests__/sound-aggregator.test.ts +1 -1
- package/src/assemble-suno-input.ts +3 -2
- package/src/camera-motions.ts +44 -42
- package/src/factory-presets/music.ts +35 -35
- package/src/hint-join.ts +26 -0
- package/src/index.ts +2 -0
- package/src/node-prompt-fields.ts +1 -0
- package/src/parameter-prompt-hint.ts +2 -1
- package/src/prompt-wizard-categories.ts +15 -3
- package/src/scene3d-reference-doctrine.ts +312 -0
- package/src/sound-aggregator.ts +1 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene3D layout-reference doctrine — what to tell a video model when a
|
|
3
|
+
* Scene3D render (the clay MP4 from 3D Render Pro or from Render Video over a
|
|
4
|
+
* 3D scene, or a still pulled from one) is attached as a reference.
|
|
5
|
+
*
|
|
6
|
+
* Two rules, both measured on a real scene (2026-09-10 greybox-to-video
|
|
7
|
+
* experiment, `seedance-2-mini`, one variable per run):
|
|
8
|
+
*
|
|
9
|
+
* 1. NEVER send a layout reference without a scoping line. A reference is a
|
|
10
|
+
* style anchor as strongly as it is a composition anchor: unscoped, a
|
|
11
|
+
* greybox in gives a greybox out (run A). One sentence naming what the
|
|
12
|
+
* reference is FOR and what to IGNORE converts the output (run C).
|
|
13
|
+
* 2. EVERY figure that must be photoreal needs its own character reference.
|
|
14
|
+
* Photoreal treatment is granted PER REFERENCED SUBJECT, not globally: a
|
|
15
|
+
* figure with no reference of its own falls back to matching the only
|
|
16
|
+
* reference that depicts it — the greybox (runs D → E). One layout
|
|
17
|
+
* reference plus one character per figure, with two slots left over for a
|
|
18
|
+
* location or style plate, is the budget that converts every figure.
|
|
19
|
+
*
|
|
20
|
+
* This module owns the WORDING. It is content, so it lives here (FSL) and not
|
|
21
|
+
* in `@nodaro/shared`. The platform (`backend/…/scene3d-reference-scoping.ts`)
|
|
22
|
+
* owns the graph walk that decides WHICH reference is a Scene3D render and what
|
|
23
|
+
* it carries; it only ever asks this module for the words.
|
|
24
|
+
*
|
|
25
|
+
* The scoping line is a RAIL CAPTION. The platform already renders one caption
|
|
26
|
+
* per video reference as `@video_N: <caption>.` (`renderReferenceCaptionLines`,
|
|
27
|
+
* the same seat an API caller fills through `referenceVideoCaptions[N]`), so
|
|
28
|
+
* the text built here carries NO leading binding and NO trailing full stop —
|
|
29
|
+
* the renderer supplies both. `renderScene3DLayoutScopingLine` reproduces the
|
|
30
|
+
* rendered form for anything that must quote the line exactly as the model
|
|
31
|
+
* sees it (the Seedance A/B harness, the docs).
|
|
32
|
+
*
|
|
33
|
+
* The line never names the target look ("photoreal", "anime"): that is the
|
|
34
|
+
* prompt's and the other references' job — rule 2 is what actually buys
|
|
35
|
+
* photoreal figures.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** What a Scene3D layout reference carries: one frame, or the rendered clip. */
|
|
39
|
+
export type Scene3DLayoutReferenceCarrier = "still" | "clip"
|
|
40
|
+
|
|
41
|
+
export interface Scene3DLayoutScopingSpec {
|
|
42
|
+
/** One frame (`still`) or the rendered clip (`clip`). */
|
|
43
|
+
readonly carries: Scene3DLayoutReferenceCarrier
|
|
44
|
+
/** Shot count in the composition the reference was rendered from, when
|
|
45
|
+
* known. More than one shot adds the cut points to what the reference is
|
|
46
|
+
* for. Ignored for a still, which is one frame of one shot. */
|
|
47
|
+
readonly shots?: number
|
|
48
|
+
/** Whether the clip includes a camera move. A still never does. */
|
|
49
|
+
readonly includesCameraMotion?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The phrase every scoping line opens with. It is the idempotence key
|
|
54
|
+
* (`hasScene3DLayoutScopingLine`) — a re-run over a prompt that already
|
|
55
|
+
* carries the line for a binding must not add a second one — and it is what an
|
|
56
|
+
* A/B log greps for. Never reword it without migrating the re-run check.
|
|
57
|
+
*/
|
|
58
|
+
export const SCENE3D_LAYOUT_SCOPING_MARKER = "LAYOUT reference only"
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What the reference is FOR — the composition properties that survive a change
|
|
62
|
+
* of look. Geometry properties, deliberately: the experiment's correction to
|
|
63
|
+
* the pipeline doc is that what a layout reference must carry is unambiguous
|
|
64
|
+
* spatial layout, which is a property of geometry, not shading.
|
|
65
|
+
*/
|
|
66
|
+
export const SCENE3D_LAYOUT_SCOPING_FOR = {
|
|
67
|
+
/** Always: where the subjects are and what is in front of what. */
|
|
68
|
+
layout: "its subject positions and blocking",
|
|
69
|
+
occlusion: "its foreground occlusion",
|
|
70
|
+
framing: "its framing",
|
|
71
|
+
cameraAngle: "its camera angle",
|
|
72
|
+
/** Clips only. */
|
|
73
|
+
cameraMotion: "its camera motion",
|
|
74
|
+
timing: "its timing",
|
|
75
|
+
/** Clips with more than one shot. */
|
|
76
|
+
cuts: (shots: number) => `its ${shots} shots and where they cut`,
|
|
77
|
+
} as const
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What to IGNORE — everything the clay render looks like. Named explicitly,
|
|
81
|
+
* one property at a time, because the model has no other way to know that the
|
|
82
|
+
* grey slabs are placeholders and not the target set dressing.
|
|
83
|
+
*/
|
|
84
|
+
export const SCENE3D_LAYOUT_SCOPING_IGNORE =
|
|
85
|
+
"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"
|
|
86
|
+
|
|
87
|
+
/** Where the look comes from instead. Generic on purpose (see module doc). */
|
|
88
|
+
export const SCENE3D_LAYOUT_SCOPING_LOOK_SOURCE = "Take the look from the prompt and from the other references"
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the scoping caption for one attached Scene3D reference.
|
|
92
|
+
*
|
|
93
|
+
* For a clip with a camera move:
|
|
94
|
+
*
|
|
95
|
+
* `LAYOUT reference only — match its subject positions and blocking, its
|
|
96
|
+
* foreground occlusion, its framing, its camera angle, its camera motion and
|
|
97
|
+
* its timing. Ignore its untextured grey clay placeholder look, its flat
|
|
98
|
+
* placeholder colours, its materials, its lighting and its empty background;
|
|
99
|
+
* none of that is the target look. Take the look from the prompt and from
|
|
100
|
+
* the other references`
|
|
101
|
+
*
|
|
102
|
+
* A still drops the motion, timing and cut clauses: one frame carries none of
|
|
103
|
+
* them, and claiming otherwise would tell the model to match motion it cannot
|
|
104
|
+
* see. No trailing full stop — the rail-caption renderer adds it.
|
|
105
|
+
*/
|
|
106
|
+
export function buildScene3DLayoutScopingLine(spec: Scene3DLayoutScopingSpec): string {
|
|
107
|
+
const matches: string[] = [
|
|
108
|
+
SCENE3D_LAYOUT_SCOPING_FOR.layout,
|
|
109
|
+
SCENE3D_LAYOUT_SCOPING_FOR.occlusion,
|
|
110
|
+
SCENE3D_LAYOUT_SCOPING_FOR.framing,
|
|
111
|
+
SCENE3D_LAYOUT_SCOPING_FOR.cameraAngle,
|
|
112
|
+
]
|
|
113
|
+
if (spec.carries === "clip") {
|
|
114
|
+
if (spec.includesCameraMotion) matches.push(SCENE3D_LAYOUT_SCOPING_FOR.cameraMotion)
|
|
115
|
+
if (typeof spec.shots === "number" && Number.isFinite(spec.shots) && spec.shots > 1) {
|
|
116
|
+
matches.push(SCENE3D_LAYOUT_SCOPING_FOR.cuts(Math.floor(spec.shots)))
|
|
117
|
+
}
|
|
118
|
+
matches.push(SCENE3D_LAYOUT_SCOPING_FOR.timing)
|
|
119
|
+
}
|
|
120
|
+
return `${SCENE3D_LAYOUT_SCOPING_MARKER} — match ${joinWithAnd(matches)}. ${SCENE3D_LAYOUT_SCOPING_IGNORE}. ${SCENE3D_LAYOUT_SCOPING_LOOK_SOURCE}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `a, b and c` — the doctrine reads as one sentence, not a list. */
|
|
124
|
+
function joinWithAnd(parts: readonly string[]): string {
|
|
125
|
+
if (parts.length <= 1) return parts[0] ?? ""
|
|
126
|
+
return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The line exactly as the model sees it once the platform has rendered the
|
|
131
|
+
* caption onto its seat: `@video_1: <caption>.` — the same shape
|
|
132
|
+
* `renderReferenceCaptionLines` emits for every rail caption. Use it wherever
|
|
133
|
+
* the rendered form must be quoted verbatim (the A/B harness, the docs); use
|
|
134
|
+
* `buildScene3DLayoutScopingLine` for what to SEND.
|
|
135
|
+
*/
|
|
136
|
+
export function renderScene3DLayoutScopingLine(binding: string, spec: Scene3DLayoutScopingSpec): string {
|
|
137
|
+
return `${binding.trim()}: ${buildScene3DLayoutScopingLine(spec)}.`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* True when `prompt` already carries a scoping line for `binding`. The check
|
|
142
|
+
* is binding + marker, not the whole caption, so a line an author typed by hand
|
|
143
|
+
* (`@video_1 is a LAYOUT reference only …`) or pasted from the docs still
|
|
144
|
+
* counts as present — the rule is "one scoping line per reference", never "our
|
|
145
|
+
* exact bytes". Per binding on purpose: `@video_1` scoped says nothing about
|
|
146
|
+
* `@video_2`.
|
|
147
|
+
*/
|
|
148
|
+
export function hasScene3DLayoutScopingLine(prompt: string | undefined, binding: string): boolean {
|
|
149
|
+
if (!prompt) return false
|
|
150
|
+
const b = binding.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
151
|
+
return new RegExp(`${b}(?::| is an?)\\s+${SCENE3D_LAYOUT_SCOPING_MARKER}`).test(prompt)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The scoping line the 2026-09-10 experiment's Seedance A/B rerun sends —
|
|
156
|
+
* the clip form, camera move included — computed from the builder so the
|
|
157
|
+
* fixture can never drift from what the platform sends. This is the text an
|
|
158
|
+
* API caller passes as `referenceVideoCaptions[N]` for the Scene3D clip on
|
|
159
|
+
* `referenceVideoUrls[N]`, and what the canvas attaches for it.
|
|
160
|
+
*/
|
|
161
|
+
export const SCENE3D_LAYOUT_REFERENCE_SCOPING_LINE = buildScene3DLayoutScopingLine({
|
|
162
|
+
carries: "clip",
|
|
163
|
+
includesCameraMotion: true,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Every wording the platform can send, plus the rendered form of each on its
|
|
168
|
+
* usual seat: `clip` on the first video-reference seat, `still` on the first
|
|
169
|
+
* image-reference seat. `clipWithCuts` is a four-shot composition.
|
|
170
|
+
*/
|
|
171
|
+
export const SCENE3D_LAYOUT_REFERENCE_SCOPING_FIXTURE = {
|
|
172
|
+
clip: SCENE3D_LAYOUT_REFERENCE_SCOPING_LINE,
|
|
173
|
+
clipRendered: renderScene3DLayoutScopingLine("@video_1", { carries: "clip", includesCameraMotion: true }),
|
|
174
|
+
clipWithCuts: buildScene3DLayoutScopingLine({ carries: "clip", shots: 4, includesCameraMotion: true }),
|
|
175
|
+
still: buildScene3DLayoutScopingLine({ carries: "still" }),
|
|
176
|
+
stillRendered: renderScene3DLayoutScopingLine("@image_1", { carries: "still" }),
|
|
177
|
+
} as const
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A Scene3D layout reference that has already been LOCATED: what it carries,
|
|
181
|
+
* which seat it landed on, and what that seat is called to the model.
|
|
182
|
+
*
|
|
183
|
+
* Finding these is a graph walk, and the graph differs per engine — the
|
|
184
|
+
* orchestrator holds `SimpleNode`s and run states, the canvas holds React Flow
|
|
185
|
+
* nodes and their own data. Applying the doctrine to them does NOT differ, and
|
|
186
|
+
* that is the half that lives here: the two functions below are the whole of
|
|
187
|
+
* rule 1's application, so a caption cannot mean one thing on a workflow run
|
|
188
|
+
* and another on the same node's Run button.
|
|
189
|
+
*/
|
|
190
|
+
export interface Scene3DLayoutReferenceSeat {
|
|
191
|
+
readonly carries: Scene3DLayoutReferenceCarrier
|
|
192
|
+
/** 0-based seat in the video rail (clip) or the leading image list (still). */
|
|
193
|
+
readonly index: number
|
|
194
|
+
/** `@video_N` / `@image_N`, exactly as the model reads the seat. */
|
|
195
|
+
readonly binding: string
|
|
196
|
+
readonly spec: Scene3DLayoutScopingSpec
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Rule 1 for clips: the rail-caption array, index-aligned with the reference
|
|
201
|
+
* video list (holes are `""`, which the renderer skips) — the same seat an API
|
|
202
|
+
* caller fills through `referenceVideoCaptions`.
|
|
203
|
+
*
|
|
204
|
+
* `undefined` when there is nothing to add, so a node with no Scene3D
|
|
205
|
+
* reference keeps its prompt byte-identical. A seat the prompt already scopes
|
|
206
|
+
* — by hand, or on a re-run over a stored prompt — gets no second line.
|
|
207
|
+
*/
|
|
208
|
+
export function scene3DLayoutVideoCaptions(
|
|
209
|
+
seats: readonly Scene3DLayoutReferenceSeat[],
|
|
210
|
+
prompt: string | undefined,
|
|
211
|
+
): string[] | undefined {
|
|
212
|
+
const clips = seats.filter((r) => r.carries === "clip" && !hasScene3DLayoutScopingLine(prompt, r.binding))
|
|
213
|
+
if (clips.length === 0) return undefined
|
|
214
|
+
const captions: string[] = []
|
|
215
|
+
for (const clip of clips) {
|
|
216
|
+
while (captions.length <= clip.index) captions.push("")
|
|
217
|
+
captions[clip.index] = buildScene3DLayoutScopingLine(clip.spec)
|
|
218
|
+
}
|
|
219
|
+
return captions
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Rule 1 for stills: an image seat has no caption seat, so the line is appended
|
|
224
|
+
* to the assembled body in the rendered form (`@image_N: <caption>.`) — the
|
|
225
|
+
* same surface a clip's caption renders to. Same idempotence as the captions.
|
|
226
|
+
*/
|
|
227
|
+
export function appendScene3DStillScopingLines(
|
|
228
|
+
prompt: string | undefined,
|
|
229
|
+
seats: readonly Scene3DLayoutReferenceSeat[],
|
|
230
|
+
): string | undefined {
|
|
231
|
+
const stills = seats.filter((r) => r.carries === "still" && !hasScene3DLayoutScopingLine(prompt, r.binding))
|
|
232
|
+
if (stills.length === 0) return prompt
|
|
233
|
+
const lines = stills.map((still) => renderScene3DLayoutScopingLine(still.binding, still.spec))
|
|
234
|
+
return prompt ? `${prompt}\n${lines.join("\n")}` : lines.join("\n")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Rule 2 — one character reference per figure
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/** Rule 2 in user terms. Quoted by the docs and by the warning below. */
|
|
242
|
+
export const SCENE3D_FIGURE_REFERENCE_RULE =
|
|
243
|
+
"Every figure that must look real needs its own character reference; a figure without one takes the clay look of the layout reference. Keep two reference slots free for a location or style plate."
|
|
244
|
+
|
|
245
|
+
/** Reference slots to keep free beside the figures — a location or style plate. */
|
|
246
|
+
export const SCENE3D_FREE_PLATE_SLOTS = 2
|
|
247
|
+
|
|
248
|
+
export const SCENE3D_UNREFERENCED_FIGURES_WARNING_CODE = "scene3d_unreferenced_figures"
|
|
249
|
+
|
|
250
|
+
export interface Scene3DFigureReferenceCheck {
|
|
251
|
+
/** Figures in the composition — `person` entities in a Scene3D v2 plan.
|
|
252
|
+
* `undefined` when the plan cannot say (a v1 plan has no entity roles), in
|
|
253
|
+
* which case there is nothing to warn about. */
|
|
254
|
+
readonly figureCount: number | undefined
|
|
255
|
+
/** Distinct character references attached to the same generation. */
|
|
256
|
+
readonly characterReferenceCount: number
|
|
257
|
+
/** The model's image-reference budget, when known. Lets the message say
|
|
258
|
+
* whether one-per-figure plus the free plate slots even fits. */
|
|
259
|
+
readonly imageReferenceCap?: number
|
|
260
|
+
/** Image seats the layout reference itself occupies: 0 for a clip (it rides
|
|
261
|
+
* the video rail), 1 for a still on an image seat. Default 0. */
|
|
262
|
+
readonly layoutReferenceImageSeats?: number
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface Scene3DUnreferencedFiguresWarning {
|
|
266
|
+
readonly code: typeof SCENE3D_UNREFERENCED_FIGURES_WARNING_CODE
|
|
267
|
+
readonly message: string
|
|
268
|
+
readonly figureCount: number
|
|
269
|
+
readonly characterReferenceCount: number
|
|
270
|
+
/** Figures still without a reference of their own. */
|
|
271
|
+
readonly missing: number
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Rule 2 as a WARNING, never a block: the run still goes out — the user may
|
|
276
|
+
* want clay figures, or be drawing a comparison — but they are told, before
|
|
277
|
+
* they pay to find out, that unreferenced figures will inherit the clay look.
|
|
278
|
+
* Returns `undefined` when there is nothing to say: no figures, an unknown
|
|
279
|
+
* count, or every figure already has a reference.
|
|
280
|
+
*/
|
|
281
|
+
export function buildScene3DUnreferencedFiguresWarning(
|
|
282
|
+
check: Scene3DFigureReferenceCheck,
|
|
283
|
+
): Scene3DUnreferencedFiguresWarning | undefined {
|
|
284
|
+
const figures = check.figureCount
|
|
285
|
+
if (figures === undefined || !Number.isFinite(figures) || figures <= 0) return undefined
|
|
286
|
+
const refs = Math.max(0, Math.floor(check.characterReferenceCount))
|
|
287
|
+
const missing = Math.floor(figures) - refs
|
|
288
|
+
if (missing <= 0) return undefined
|
|
289
|
+
const figureWord = figures === 1 ? "figure" : "figures"
|
|
290
|
+
const refWord = refs === 1 ? "character reference is" : "character references are"
|
|
291
|
+
let message =
|
|
292
|
+
`The layout reference shows ${figures} ${figureWord} but ${refs} ${refWord} attached. ` +
|
|
293
|
+
`A figure without its own character reference takes the clay look of the layout reference. ` +
|
|
294
|
+
`Attach one character reference per figure (${missing} more)`
|
|
295
|
+
const cap = check.imageReferenceCap
|
|
296
|
+
if (typeof cap === "number" && Number.isFinite(cap) && cap > 0) {
|
|
297
|
+
const seats = Math.max(0, Math.floor(check.layoutReferenceImageSeats ?? 0))
|
|
298
|
+
const spare = cap - seats - figures
|
|
299
|
+
message += spare >= SCENE3D_FREE_PLATE_SLOTS
|
|
300
|
+
? `; this model takes ${cap} image references, which leaves ${spare} for a location or style plate.`
|
|
301
|
+
: `; this model takes ${cap} image references, so one per figure plus ${SCENE3D_FREE_PLATE_SLOTS} free slots for a location or style plate does not fit — reference the figures that matter most first.`
|
|
302
|
+
} else {
|
|
303
|
+
message += "."
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
code: SCENE3D_UNREFERENCED_FIGURES_WARNING_CODE,
|
|
307
|
+
message,
|
|
308
|
+
figureCount: figures,
|
|
309
|
+
characterReferenceCount: refs,
|
|
310
|
+
missing,
|
|
311
|
+
}
|
|
312
|
+
}
|
package/src/sound-aggregator.ts
CHANGED
|
@@ -96,7 +96,7 @@ export function composeSoundHintFromConnections(
|
|
|
96
96
|
// Music consumers (suno-generate, generate-music) accept BOTH music nodes
|
|
97
97
|
// AND voice nodes — voice description (gender, age, accent, language,
|
|
98
98
|
// timbre, delivery archetype) is valid input for music with vocals. Suno
|
|
99
|
-
//
|
|
99
|
+
// V6 in particular benefits from rich voice description; the typed
|
|
100
100
|
// `vocalGender` field on Suno is also extracted below from voice-character.
|
|
101
101
|
//
|
|
102
102
|
// Voice Design rejects music nodes (different domain). Text-to-Audio
|