@nodaro/prompts 1.17.1 → 1.18.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 +123 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +207 -1
- package/dist/index.d.ts +207 -1
- package/dist/index.js +109 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/node-prompt-fields.test.ts +3 -2
- package/src/__tests__/scene3d-reference-doctrine.test.ts +142 -0
- package/src/index.ts +1 -0
- package/src/node-prompt-fields.ts +1 -0
- package/src/prompt-wizard-categories.ts +6 -0
- package/src/scene3d-reference-doctrine.ts +312 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodaro/prompts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.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": "^
|
|
23
|
+
"@nodaro/shared": "^3.8.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.5.0",
|
|
@@ -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 (
|
|
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(
|
|
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
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -80,6 +80,7 @@ export * from "./wardrobe.js"
|
|
|
80
80
|
export * from "./prompt-templates.js"
|
|
81
81
|
export * from "./provider-prompt-doctrine.js"
|
|
82
82
|
export * from "./image-reference-doctrine.js"
|
|
83
|
+
export * from "./scene3d-reference-doctrine.js"
|
|
83
84
|
export * from "./prompt-wizard-categories.js"
|
|
84
85
|
export * from "./resolve-prompt.js"
|
|
85
86
|
export * from "./node-prompt-fields.js"
|
|
@@ -118,6 +118,7 @@ export const NODE_PROMPT_FIELDS: Readonly<Record<string, PromptFieldSpec>> = {
|
|
|
118
118
|
// ── Composition / FX (compact, no media-result preview → no inline editor) ──
|
|
119
119
|
"image-critic": { prompt: "prompt", promptLabel: "Criteria", media: "image", inline: false },
|
|
120
120
|
"motion-graphics": { prompt: "motionPrompt", promptLabel: "Motion prompt", media: "video", inline: false },
|
|
121
|
+
"pro-3d-render": { prompt: "scenePrompt", promptLabel: "Scene", media: "text", inline: false },
|
|
121
122
|
"generate-3d-scene": { prompt: "scenePrompt", promptLabel: "Scene", media: "text", inline: false },
|
|
122
123
|
"edit-3d-scene": { prompt: "editPrompt", promptLabel: "Edit instruction", media: "text", inline: false },
|
|
123
124
|
"3d-title": { prompt: "titlePrompt", promptLabel: "Title", media: "text", inline: false },
|
|
@@ -165,6 +165,8 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
|
|
|
165
165
|
"nano-banana-2-lite": "Fast, low-cost 1K generation for drafts and iteration",
|
|
166
166
|
"gpt-image": "Creative concepts, illustration, variable quality tiers",
|
|
167
167
|
"gpt-image-2": "Latest GPT Image — sharp text, photorealism, 1K/2K/4K resolution",
|
|
168
|
+
"gpt-image-2-5-flare": "Fast everyday GPT Image 2.5 — higher quality than GPT Image 2 at ~half the latency; default for iteration, social, high volume",
|
|
169
|
+
"gpt-image-2-5-sunburst": "Precision GPT Image 2.5 — slower, tighter control and detail fidelity; brand-sensitive, packaging, diagrams, final creative",
|
|
168
170
|
"grok": "General purpose, good text understanding",
|
|
169
171
|
"grok-2": "Grok Imagine 2 — expressive, high-contrast, stylized output",
|
|
170
172
|
"imagen4": "Google's latest, strong photorealism and text rendering",
|
|
@@ -190,6 +192,8 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
|
|
|
190
192
|
"flux-pro-i2i": "Premium Flux transformation",
|
|
191
193
|
"gpt-image-i2i": "Creative reinterpretation of source images",
|
|
192
194
|
"gpt-image-2-i2i": "Latest GPT Image — pixel-level edits with original lighting/texture preservation, up to 4K",
|
|
195
|
+
"gpt-image-2-5-flare-i2i": "Fast GPT Image 2.5 edits — up to 16 sources; use while iterating",
|
|
196
|
+
"gpt-image-2-5-sunburst-i2i": "Precision GPT Image 2.5 edits — up to 16 sources, tightest control on demanding retouches; slower",
|
|
193
197
|
"ideogram-edit": "Instruction-based editing with text preservation",
|
|
194
198
|
"ideogram-remix": "Style remixing while preserving structure",
|
|
195
199
|
"ideogram-reframe": "Aspect ratio changes with AI fill",
|
|
@@ -213,6 +217,8 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
|
|
|
213
217
|
"flux-pro-i2i": "Premium Flux transformation",
|
|
214
218
|
"gpt-image-i2i": "Creative reinterpretation of source images",
|
|
215
219
|
"gpt-image-2-i2i": "Latest GPT Image — pixel-level edits with original lighting/texture preservation, up to 4K",
|
|
220
|
+
"gpt-image-2-5-flare-i2i": "Fast GPT Image 2.5 edits — up to 16 sources; use while iterating",
|
|
221
|
+
"gpt-image-2-5-sunburst-i2i": "Precision GPT Image 2.5 edits — up to 16 sources, tightest control on demanding retouches; slower",
|
|
216
222
|
"ideogram-edit": "Instruction-based editing with text preservation",
|
|
217
223
|
"ideogram-remix": "Style remixing while preserving structure",
|
|
218
224
|
"ideogram-reframe": "Aspect ratio changes with AI fill",
|
|
@@ -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
|
+
}
|