@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.
@@ -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
+ }
@@ -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
- // V5 in particular benefits from rich voice description; the typed
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