@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,1773 @@
|
|
|
1
|
+
import { resolveTemplate, applyTemplate, DEFAULT_TEMPLATES } from "../prompt-templates.js"
|
|
2
|
+
import {
|
|
3
|
+
buildImagePrompt,
|
|
4
|
+
expandImagePositionRefs,
|
|
5
|
+
buildScenePrompt,
|
|
6
|
+
SCENE_PROMPT_MAX_LENGTH,
|
|
7
|
+
} from "../prompt-builder.js"
|
|
8
|
+
import type { CharacterDef, SceneData } from "@nodaro/shared"
|
|
9
|
+
import type { BuildImagePromptConfig } from "../prompt-builder.js"
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Build a minimal SceneData with required fields, overridable via partial. */
|
|
16
|
+
function makeScene(overrides: Partial<SceneData> = {}): SceneData {
|
|
17
|
+
return {
|
|
18
|
+
shotType: "medium",
|
|
19
|
+
cameraAngle: "eye-level",
|
|
20
|
+
aspectRatio: "16:9",
|
|
21
|
+
characters: [],
|
|
22
|
+
objects: [],
|
|
23
|
+
mood: [],
|
|
24
|
+
visualStyle: "",
|
|
25
|
+
depthOfField: "medium",
|
|
26
|
+
lensType: "normal",
|
|
27
|
+
cameraMovement: "static",
|
|
28
|
+
colorPalette: [],
|
|
29
|
+
summary: "",
|
|
30
|
+
timeOfDay: "noon",
|
|
31
|
+
weather: "clear",
|
|
32
|
+
lighting: "natural",
|
|
33
|
+
...overrides,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeCharDef(overrides: Partial<CharacterDef> = {}): CharacterDef {
|
|
38
|
+
return {
|
|
39
|
+
id: "c1",
|
|
40
|
+
name: "Alice",
|
|
41
|
+
type: "description",
|
|
42
|
+
description: "a brave warrior",
|
|
43
|
+
...overrides,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// resolveTemplate
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
describe("resolveTemplate", () => {
|
|
51
|
+
it("returns default template for a known key", () => {
|
|
52
|
+
expect(resolveTemplate("character-description")).toBe(
|
|
53
|
+
DEFAULT_TEMPLATES["character-description"],
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("returns empty string for an unknown key", () => {
|
|
58
|
+
expect(resolveTemplate("nonexistent-key")).toBe("")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("returns empty string for unknown key with empty overrides", () => {
|
|
62
|
+
expect(resolveTemplate("nonexistent-key", {}, {})).toBe("")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("user override takes precedence over default", () => {
|
|
66
|
+
const userTemplates = { "character-description": "Custom: {name}" }
|
|
67
|
+
expect(resolveTemplate("character-description", userTemplates)).toBe("Custom: {name}")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("flow override takes precedence over user override", () => {
|
|
71
|
+
const userTemplates = { "character-description": "User: {name}" }
|
|
72
|
+
const flowTemplates = { "character-description": "Flow: {name}" }
|
|
73
|
+
expect(resolveTemplate("character-description", userTemplates, flowTemplates)).toBe(
|
|
74
|
+
"Flow: {name}",
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("flow override takes precedence over default when no user override", () => {
|
|
79
|
+
const flowTemplates = { "character-description": "Flow: {name}" }
|
|
80
|
+
expect(resolveTemplate("character-description", undefined, flowTemplates)).toBe(
|
|
81
|
+
"Flow: {name}",
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("falls through to default when flow and user do not have the key", () => {
|
|
86
|
+
const userTemplates = { "other-key": "irrelevant" }
|
|
87
|
+
const flowTemplates = { "another-key": "irrelevant" }
|
|
88
|
+
expect(resolveTemplate("character-description", userTemplates, flowTemplates)).toBe(
|
|
89
|
+
DEFAULT_TEMPLATES["character-description"],
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// applyTemplate
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
describe("applyTemplate", () => {
|
|
98
|
+
it("replaces named placeholders with values", () => {
|
|
99
|
+
expect(applyTemplate("{name} is {description}", { name: "Foo", description: "bar" })).toBe(
|
|
100
|
+
"Foo is bar",
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("replaces missing vars with empty string", () => {
|
|
105
|
+
expect(applyTemplate("{name} is {description}", { name: "Foo" })).toBe("Foo is {description}")
|
|
106
|
+
// Note: only keys present in vars are replaced. Missing keys stay as-is in template.
|
|
107
|
+
// But if a key IS present with empty value, it is replaced with "".
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("replaces vars that have empty string value with empty string", () => {
|
|
111
|
+
expect(applyTemplate("{name} is {description}", { name: "", description: "bar" })).toBe(
|
|
112
|
+
" is bar",
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("replaces multiple occurrences of the same placeholder", () => {
|
|
117
|
+
expect(applyTemplate("{x} and {x}", { x: "hello" })).toBe("hello and hello")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("handles template with no placeholders", () => {
|
|
121
|
+
expect(applyTemplate("no placeholders here", { name: "Foo" })).toBe("no placeholders here")
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("handles empty template", () => {
|
|
125
|
+
expect(applyTemplate("", { name: "Foo" })).toBe("")
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("handles empty vars", () => {
|
|
129
|
+
expect(applyTemplate("{name}", {})).toBe("{name}")
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// buildImagePrompt
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
describe("buildImagePrompt", () => {
|
|
137
|
+
describe("basic prompt passthrough", () => {
|
|
138
|
+
it("returns prompt unchanged when no extras are provided", () => {
|
|
139
|
+
const result = buildImagePrompt({ prompt: "A sunset", provider: "nano-banana" })
|
|
140
|
+
expect(result.prompt).toBe("A sunset")
|
|
141
|
+
expect(result.nativeNegativePrompt).toBeUndefined()
|
|
142
|
+
expect(result.referenceImageUrls).toBeUndefined()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("trims nothing for short prompts", () => {
|
|
146
|
+
const result = buildImagePrompt({ prompt: "short", provider: "flux" })
|
|
147
|
+
expect(result.prompt).toBe("short")
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe("style appending", () => {
|
|
152
|
+
it("expands a known STYLES catalog id into its richer promptHint", () => {
|
|
153
|
+
const result = buildImagePrompt({
|
|
154
|
+
prompt: "A sunset over mountains",
|
|
155
|
+
provider: "flux",
|
|
156
|
+
style: "cinematic",
|
|
157
|
+
})
|
|
158
|
+
expect(result.prompt).toBe(
|
|
159
|
+
"A sunset over mountains\nStyle: cinematic film style, dramatic lighting with cinematic color grading, widescreen aesthetic and film-like depth of field",
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("falls back to the raw text when style is not a catalog id", () => {
|
|
164
|
+
const result = buildImagePrompt({
|
|
165
|
+
prompt: "A sunset over mountains",
|
|
166
|
+
provider: "flux",
|
|
167
|
+
style: "my custom style",
|
|
168
|
+
})
|
|
169
|
+
expect(result.prompt).toBe("A sunset over mountains\nStyle: my custom style")
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("ignores whitespace-only style", () => {
|
|
173
|
+
const result = buildImagePrompt({
|
|
174
|
+
prompt: "A sunset",
|
|
175
|
+
provider: "flux",
|
|
176
|
+
style: " ",
|
|
177
|
+
})
|
|
178
|
+
expect(result.prompt).toBe("A sunset")
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("ignores undefined style", () => {
|
|
182
|
+
const result = buildImagePrompt({
|
|
183
|
+
prompt: "A sunset",
|
|
184
|
+
provider: "flux",
|
|
185
|
+
style: undefined,
|
|
186
|
+
})
|
|
187
|
+
expect(result.prompt).toBe("A sunset")
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe("negative prompt routing", () => {
|
|
192
|
+
it("sets nativeNegativePrompt for imagen4 (native model)", () => {
|
|
193
|
+
const result = buildImagePrompt({
|
|
194
|
+
prompt: "A sunset",
|
|
195
|
+
provider: "imagen4",
|
|
196
|
+
negativePrompt: "blurry",
|
|
197
|
+
})
|
|
198
|
+
expect(result.nativeNegativePrompt).toBe("blurry")
|
|
199
|
+
expect(result.prompt).not.toContain("Avoid:")
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("sets nativeNegativePrompt for imagen4-fast", () => {
|
|
203
|
+
const result = buildImagePrompt({
|
|
204
|
+
prompt: "A sunset",
|
|
205
|
+
provider: "imagen4-fast",
|
|
206
|
+
negativePrompt: "blurry, dark",
|
|
207
|
+
})
|
|
208
|
+
expect(result.nativeNegativePrompt).toBe("blurry, dark")
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("sets nativeNegativePrompt for ideogram-v3", () => {
|
|
212
|
+
const result = buildImagePrompt({
|
|
213
|
+
prompt: "A sunset",
|
|
214
|
+
provider: "ideogram-v3",
|
|
215
|
+
negativePrompt: "ugly",
|
|
216
|
+
})
|
|
217
|
+
expect(result.nativeNegativePrompt).toBe("ugly")
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("sets nativeNegativePrompt for qwen", () => {
|
|
221
|
+
const result = buildImagePrompt({
|
|
222
|
+
prompt: "A sunset",
|
|
223
|
+
provider: "qwen",
|
|
224
|
+
negativePrompt: "text",
|
|
225
|
+
})
|
|
226
|
+
expect(result.nativeNegativePrompt).toBe("text")
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("appends negative prompt as 'Avoid:' for non-native models", () => {
|
|
230
|
+
const result = buildImagePrompt({
|
|
231
|
+
prompt: "A sunset",
|
|
232
|
+
provider: "flux",
|
|
233
|
+
negativePrompt: "blurry",
|
|
234
|
+
})
|
|
235
|
+
expect(result.nativeNegativePrompt).toBeUndefined()
|
|
236
|
+
expect(result.prompt).toBe("A sunset\nAvoid: blurry")
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("appends negative prompt as 'Avoid:' for gpt-image", () => {
|
|
240
|
+
const result = buildImagePrompt({
|
|
241
|
+
prompt: "A sunset",
|
|
242
|
+
provider: "gpt-image",
|
|
243
|
+
negativePrompt: "watermark",
|
|
244
|
+
})
|
|
245
|
+
expect(result.nativeNegativePrompt).toBeUndefined()
|
|
246
|
+
expect(result.prompt).toContain("Avoid: watermark")
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it("ignores whitespace-only negative prompt", () => {
|
|
250
|
+
const result = buildImagePrompt({
|
|
251
|
+
prompt: "A sunset",
|
|
252
|
+
provider: "flux",
|
|
253
|
+
negativePrompt: " ",
|
|
254
|
+
})
|
|
255
|
+
expect(result.nativeNegativePrompt).toBeUndefined()
|
|
256
|
+
expect(result.prompt).not.toContain("Avoid:")
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("combines style and negative prompt correctly", () => {
|
|
260
|
+
const result = buildImagePrompt({
|
|
261
|
+
prompt: "A sunset",
|
|
262
|
+
provider: "flux",
|
|
263
|
+
style: "my custom style",
|
|
264
|
+
negativePrompt: "blurry",
|
|
265
|
+
})
|
|
266
|
+
expect(result.prompt).toBe("A sunset\nStyle: my custom style\nAvoid: blurry")
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("combines style with native negative prompt", () => {
|
|
270
|
+
const result = buildImagePrompt({
|
|
271
|
+
prompt: "A sunset",
|
|
272
|
+
provider: "imagen4",
|
|
273
|
+
style: "my custom style",
|
|
274
|
+
negativePrompt: "blurry",
|
|
275
|
+
})
|
|
276
|
+
expect(result.prompt).toBe("A sunset\nStyle: my custom style")
|
|
277
|
+
expect(result.nativeNegativePrompt).toBe("blurry")
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
describe("character description expansion", () => {
|
|
282
|
+
it("expands character descriptions using default templates", () => {
|
|
283
|
+
const result = buildImagePrompt({
|
|
284
|
+
prompt: "A scene",
|
|
285
|
+
provider: "flux",
|
|
286
|
+
characterDefs: [
|
|
287
|
+
makeCharDef({ name: "Alice", description: "a brave warrior" }),
|
|
288
|
+
],
|
|
289
|
+
})
|
|
290
|
+
expect(result.prompt).toContain("A scene")
|
|
291
|
+
expect(result.prompt).toContain("Include character 'Alice': a brave warrior.")
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it("expands face category using face template", () => {
|
|
295
|
+
const result = buildImagePrompt({
|
|
296
|
+
prompt: "Portrait",
|
|
297
|
+
provider: "flux",
|
|
298
|
+
characterDefs: [
|
|
299
|
+
makeCharDef({ name: "Bob", category: "face", description: "strong jawline" }),
|
|
300
|
+
],
|
|
301
|
+
})
|
|
302
|
+
expect(result.prompt).toContain("Include the exact face and facial features of 'Bob'")
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("expands location category using location template", () => {
|
|
306
|
+
const result = buildImagePrompt({
|
|
307
|
+
prompt: "Scene",
|
|
308
|
+
provider: "flux",
|
|
309
|
+
characterDefs: [
|
|
310
|
+
makeCharDef({ name: "Castle", category: "location", description: "a medieval castle" }),
|
|
311
|
+
],
|
|
312
|
+
})
|
|
313
|
+
expect(result.prompt).toContain("Include location 'Castle': a medieval castle.")
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it("expands object category using object template", () => {
|
|
317
|
+
const result = buildImagePrompt({
|
|
318
|
+
prompt: "Scene",
|
|
319
|
+
provider: "flux",
|
|
320
|
+
characterDefs: [
|
|
321
|
+
makeCharDef({ name: "Sword", category: "object", description: "a glowing sword" }),
|
|
322
|
+
],
|
|
323
|
+
})
|
|
324
|
+
expect(result.prompt).toContain("Include object 'Sword': a glowing sword.")
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("skips reference-type characters (no description expansion)", () => {
|
|
328
|
+
const result = buildImagePrompt({
|
|
329
|
+
prompt: "A scene",
|
|
330
|
+
provider: "flux",
|
|
331
|
+
characterDefs: [
|
|
332
|
+
makeCharDef({ type: "reference", description: undefined }),
|
|
333
|
+
],
|
|
334
|
+
})
|
|
335
|
+
// No character descriptions appended
|
|
336
|
+
expect(result.prompt).toBe("A scene")
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it("skips description-type characters with no description", () => {
|
|
340
|
+
const result = buildImagePrompt({
|
|
341
|
+
prompt: "A scene",
|
|
342
|
+
provider: "flux",
|
|
343
|
+
characterDefs: [
|
|
344
|
+
makeCharDef({ description: undefined }),
|
|
345
|
+
],
|
|
346
|
+
})
|
|
347
|
+
expect(result.prompt).toBe("A scene")
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it("joins multiple character descriptions with spaces", () => {
|
|
351
|
+
const result = buildImagePrompt({
|
|
352
|
+
prompt: "A scene",
|
|
353
|
+
provider: "flux",
|
|
354
|
+
characterDefs: [
|
|
355
|
+
makeCharDef({ id: "c1", name: "Alice", description: "warrior" }),
|
|
356
|
+
makeCharDef({ id: "c2", name: "Bob", description: "wizard" }),
|
|
357
|
+
],
|
|
358
|
+
})
|
|
359
|
+
expect(result.prompt).toContain("Include character 'Alice': warrior.")
|
|
360
|
+
expect(result.prompt).toContain("Include character 'Bob': wizard.")
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it("uses flow template overrides for character expansion", () => {
|
|
364
|
+
const result = buildImagePrompt({
|
|
365
|
+
prompt: "A scene",
|
|
366
|
+
provider: "flux",
|
|
367
|
+
characterDefs: [makeCharDef({ name: "Alice", description: "warrior" })],
|
|
368
|
+
flowTemplates: { "character-description": "Show {name} who is {description}!" },
|
|
369
|
+
})
|
|
370
|
+
expect(result.prompt).toContain("Show Alice who is warrior!")
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it("uses wrapper template to combine user prompt and descriptions", () => {
|
|
374
|
+
const result = buildImagePrompt({
|
|
375
|
+
prompt: "A battle",
|
|
376
|
+
provider: "flux",
|
|
377
|
+
characterDefs: [makeCharDef({ name: "Alice", description: "warrior" })],
|
|
378
|
+
flowTemplates: {
|
|
379
|
+
"generate-image-wrapper": "Prompt: {userPrompt} | Characters: {assetDescriptions}",
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
expect(result.prompt).toContain("Prompt: A battle | Characters:")
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
describe("truncation", () => {
|
|
387
|
+
// The assembled-prompt cap is the PROVIDER's max (default 5000 =
|
|
388
|
+
// IMAGE_PROMPT_MAX, the documented KIE standard + what the image routes
|
|
389
|
+
// already accept), NOT the old hardcoded 2000. The person/cinematography
|
|
390
|
+
// hints + the `Avoid:` negative are appended at the END of the assembled
|
|
391
|
+
// prompt, so a 2000-char tail-cut silently severed exactly those whenever
|
|
392
|
+
// the user's prose was long. Regression for the 2026-06-13 report.
|
|
393
|
+
it("does NOT truncate a 2500-char prompt (cap is 5000, not 2000)", () => {
|
|
394
|
+
const longPrompt = "x".repeat(2500)
|
|
395
|
+
const result = buildImagePrompt({ prompt: longPrompt, provider: "flux" })
|
|
396
|
+
expect(result.prompt).toBe(longPrompt)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it("keeps appended style + negative on a long (2500-char) prompt", () => {
|
|
400
|
+
// 2500 prose + style + avoid is well past the OLD 2000 cap but under
|
|
401
|
+
// 5000 → the picker/style/negative tail must survive untouched.
|
|
402
|
+
const basePrompt = "a".repeat(2500)
|
|
403
|
+
const result = buildImagePrompt({
|
|
404
|
+
prompt: basePrompt,
|
|
405
|
+
provider: "flux", // non-native negative → "Avoid:" appended to prompt
|
|
406
|
+
style: "cinematic",
|
|
407
|
+
negativePrompt: "blurry",
|
|
408
|
+
})
|
|
409
|
+
expect(result.prompt.startsWith("a".repeat(2500))).toBe(true)
|
|
410
|
+
expect(result.prompt).toContain("Style:")
|
|
411
|
+
expect(result.prompt).toContain("Avoid: blurry")
|
|
412
|
+
expect(result.prompt.endsWith("...")).toBe(false)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it("truncates a prompt that exceeds the 5000 cap", () => {
|
|
416
|
+
const huge = "y".repeat(6000)
|
|
417
|
+
const result = buildImagePrompt({ prompt: huge, provider: "flux" })
|
|
418
|
+
expect(result.prompt.length).toBe(5000)
|
|
419
|
+
expect(result.prompt.endsWith("...")).toBe(true)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it("preserves Style/Avoid even when the body must be truncated past the cap", () => {
|
|
423
|
+
// The control suffixes are reserved first, so even a body that alone blows
|
|
424
|
+
// past the cap can never sever the negative — the exact failure the old
|
|
425
|
+
// 2000 tail-cut caused.
|
|
426
|
+
const huge = "a".repeat(6000)
|
|
427
|
+
const result = buildImagePrompt({
|
|
428
|
+
prompt: huge,
|
|
429
|
+
provider: "flux",
|
|
430
|
+
style: "cinematic",
|
|
431
|
+
negativePrompt: "blurry",
|
|
432
|
+
})
|
|
433
|
+
expect(result.prompt.length).toBeLessThanOrEqual(5000)
|
|
434
|
+
expect(result.prompt).toContain("Style:")
|
|
435
|
+
expect(result.prompt).toContain("Avoid: blurry")
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it("does not truncate a prompt under the cap", () => {
|
|
439
|
+
const shortPrompt = "z".repeat(4999)
|
|
440
|
+
const result = buildImagePrompt({ prompt: shortPrompt, provider: "flux" })
|
|
441
|
+
expect(result.prompt).toBe(shortPrompt)
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
describe("reference image filtering", () => {
|
|
446
|
+
it("passes refs through for nano-banana (supported model)", () => {
|
|
447
|
+
const refs = ["https://img.example.com/1.png", "https://img.example.com/2.png"]
|
|
448
|
+
const result = buildImagePrompt({
|
|
449
|
+
prompt: "A scene",
|
|
450
|
+
provider: "nano-banana",
|
|
451
|
+
referenceImageUrls: refs,
|
|
452
|
+
})
|
|
453
|
+
expect(result.referenceImageUrls).toEqual(refs)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it("passes refs through for nano-banana-pro (supported model)", () => {
|
|
457
|
+
const refs = ["https://img.example.com/1.png"]
|
|
458
|
+
const result = buildImagePrompt({
|
|
459
|
+
prompt: "A scene",
|
|
460
|
+
provider: "nano-banana-pro",
|
|
461
|
+
referenceImageUrls: refs,
|
|
462
|
+
})
|
|
463
|
+
expect(result.referenceImageUrls).toEqual(refs)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it("passes refs through for nano-banana-2 (supported model)", () => {
|
|
467
|
+
const refs = ["https://img.example.com/1.png"]
|
|
468
|
+
const result = buildImagePrompt({
|
|
469
|
+
prompt: "A scene",
|
|
470
|
+
provider: "nano-banana-2",
|
|
471
|
+
referenceImageUrls: refs,
|
|
472
|
+
})
|
|
473
|
+
expect(result.referenceImageUrls).toEqual(refs)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it("passes refs through for gpt-image (auto-routed to i2i variant on backend)", () => {
|
|
477
|
+
const refs = ["https://img.example.com/1.png"]
|
|
478
|
+
const result = buildImagePrompt({
|
|
479
|
+
prompt: "A scene",
|
|
480
|
+
provider: "gpt-image",
|
|
481
|
+
referenceImageUrls: refs,
|
|
482
|
+
})
|
|
483
|
+
// gpt-image is in MODELS_WITH_REFERENCE_IMAGE_SUPPORT because the backend
|
|
484
|
+
// route auto-routes to gpt-image-i2i when refs are addressed.
|
|
485
|
+
expect(result.referenceImageUrls).toEqual(refs)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it("passes refs through for gpt-image-i2i (i2i variant supports refs)", () => {
|
|
489
|
+
const refs = ["https://img.example.com/1.png", "https://img.example.com/2.png"]
|
|
490
|
+
const result = buildImagePrompt({
|
|
491
|
+
prompt: "A scene",
|
|
492
|
+
provider: "gpt-image-i2i",
|
|
493
|
+
referenceImageUrls: refs,
|
|
494
|
+
})
|
|
495
|
+
expect(result.referenceImageUrls).toEqual(refs)
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it("passes refs through for flux (auto-routed to flux-pro-i2i on backend)", () => {
|
|
499
|
+
const refs = ["https://img.example.com/1.png"]
|
|
500
|
+
const result = buildImagePrompt({
|
|
501
|
+
prompt: "A scene",
|
|
502
|
+
provider: "flux",
|
|
503
|
+
referenceImageUrls: refs,
|
|
504
|
+
})
|
|
505
|
+
expect(result.referenceImageUrls).toEqual(refs)
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it("returns undefined refs for imagen4 (no i2i sibling, refs unusable)", () => {
|
|
509
|
+
const refs = ["https://img.example.com/1.png"]
|
|
510
|
+
const result = buildImagePrompt({
|
|
511
|
+
prompt: "A scene",
|
|
512
|
+
provider: "imagen4",
|
|
513
|
+
referenceImageUrls: refs,
|
|
514
|
+
})
|
|
515
|
+
expect(result.referenceImageUrls).toBeUndefined()
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it("returns undefined refs when supported model has empty refs array", () => {
|
|
519
|
+
const result = buildImagePrompt({
|
|
520
|
+
prompt: "A scene",
|
|
521
|
+
provider: "nano-banana",
|
|
522
|
+
referenceImageUrls: [],
|
|
523
|
+
})
|
|
524
|
+
expect(result.referenceImageUrls).toBeUndefined()
|
|
525
|
+
})
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
describe("ancestor ref fallback", () => {
|
|
529
|
+
it("uses ancestor refs when no direct refs are provided", () => {
|
|
530
|
+
const ancestors = ["https://img.example.com/ancestor.png"]
|
|
531
|
+
const result = buildImagePrompt({
|
|
532
|
+
prompt: "A scene",
|
|
533
|
+
provider: "nano-banana",
|
|
534
|
+
referenceImageUrls: [],
|
|
535
|
+
ancestorRefs: ancestors,
|
|
536
|
+
})
|
|
537
|
+
expect(result.referenceImageUrls).toEqual(ancestors)
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it("prefers direct refs over ancestor refs", () => {
|
|
541
|
+
const directRefs = ["https://img.example.com/direct.png"]
|
|
542
|
+
const ancestorRefs = ["https://img.example.com/ancestor.png"]
|
|
543
|
+
const result = buildImagePrompt({
|
|
544
|
+
prompt: "A scene",
|
|
545
|
+
provider: "nano-banana",
|
|
546
|
+
referenceImageUrls: directRefs,
|
|
547
|
+
ancestorRefs: ancestorRefs,
|
|
548
|
+
})
|
|
549
|
+
expect(result.referenceImageUrls).toEqual(directRefs)
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it("ancestor refs are filtered for providers without ref support and no i2i sibling", () => {
|
|
553
|
+
const ancestors = ["https://img.example.com/ancestor.png"]
|
|
554
|
+
const result = buildImagePrompt({
|
|
555
|
+
prompt: "A scene",
|
|
556
|
+
provider: "imagen4", // pure T2I, no i2i sibling, refs unusable
|
|
557
|
+
referenceImageUrls: [],
|
|
558
|
+
ancestorRefs: ancestors,
|
|
559
|
+
})
|
|
560
|
+
expect(result.referenceImageUrls).toBeUndefined()
|
|
561
|
+
})
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
describe("{image:N} expansion in prompt", () => {
|
|
565
|
+
it("expands {image:1} (no label) using numeric naming", () => {
|
|
566
|
+
const result = buildImagePrompt({
|
|
567
|
+
prompt: "{image:1} shows a cat",
|
|
568
|
+
provider: "nano-banana",
|
|
569
|
+
referenceImageUrls: ["https://img.example.com/1.png", "https://img.example.com/2.png"],
|
|
570
|
+
})
|
|
571
|
+
expect(result.prompt).toContain("Image 1 shows a cat")
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it("expands multiple image references with numeric indices", () => {
|
|
575
|
+
const result = buildImagePrompt({
|
|
576
|
+
prompt: "{image:1} and {image:2}",
|
|
577
|
+
provider: "nano-banana",
|
|
578
|
+
referenceImageUrls: ["https://a.png", "https://b.png"],
|
|
579
|
+
})
|
|
580
|
+
expect(result.prompt).toContain("Image 1 and Image 2")
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it("does not expand {image:N} for out-of-range index", () => {
|
|
584
|
+
const result = buildImagePrompt({
|
|
585
|
+
prompt: "{image:3} is missing",
|
|
586
|
+
provider: "nano-banana",
|
|
587
|
+
referenceImageUrls: ["https://a.png", "https://b.png"],
|
|
588
|
+
})
|
|
589
|
+
expect(result.prompt).toContain("{image:3} is missing")
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it("uses ancestor ref count for image expansion when no direct refs", () => {
|
|
593
|
+
const result = buildImagePrompt({
|
|
594
|
+
prompt: "{image:1} from ancestor",
|
|
595
|
+
provider: "nano-banana",
|
|
596
|
+
referenceImageUrls: [],
|
|
597
|
+
ancestorRefs: ["https://ancestor.png"],
|
|
598
|
+
})
|
|
599
|
+
expect(result.prompt).toContain("Image 1 from ancestor")
|
|
600
|
+
})
|
|
601
|
+
})
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// buildImagePrompt — connectedReferences + identity-based directives
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
describe("buildImagePrompt with connectedReferences", () => {
|
|
608
|
+
it("emits NO directives when the user prompt contains no {image:N:label} mentions", () => {
|
|
609
|
+
const result = buildImagePrompt({
|
|
610
|
+
prompt: "wearing a red hat",
|
|
611
|
+
provider: "nano-banana",
|
|
612
|
+
connectedReferences: [
|
|
613
|
+
{ id: "img1", defaultName: "Image 1", source: "manual", url: "https://a.png" },
|
|
614
|
+
{ id: "img2", defaultName: "Image 2", source: "manual", url: "https://b.png" },
|
|
615
|
+
],
|
|
616
|
+
})
|
|
617
|
+
// No mentions = no directives. The user prompt passes through unchanged.
|
|
618
|
+
expect(result.prompt).not.toContain("Use these references for the output image:")
|
|
619
|
+
expect(result.prompt).not.toContain("Compose them naturally")
|
|
620
|
+
expect(result.prompt).toBe("wearing a red hat")
|
|
621
|
+
// URLs are still sent — the references are attached even if not mentioned.
|
|
622
|
+
expect(result.referenceImageUrls).toEqual(["https://a.png", "https://b.png"])
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it("only emits directives for mentioned identities (not for attached-but-unmentioned refs)", () => {
|
|
626
|
+
const result = buildImagePrompt({
|
|
627
|
+
prompt: "{image:1:dragon} flying",
|
|
628
|
+
provider: "nano-banana",
|
|
629
|
+
connectedReferences: [
|
|
630
|
+
{ id: "a", defaultName: "Image 1", source: "manual", url: "https://a.png" },
|
|
631
|
+
{ id: "b", defaultName: "Image 2", source: "manual", url: "https://b.png" },
|
|
632
|
+
],
|
|
633
|
+
})
|
|
634
|
+
expect(result.prompt).toContain("- Image 1 (dragon) — match exactly.")
|
|
635
|
+
// Image 2 is attached but not mentioned → no directive about it
|
|
636
|
+
expect(result.prompt).not.toContain("Image 2")
|
|
637
|
+
// But its URL is still sent to the provider
|
|
638
|
+
expect(result.referenceImageUrls).toEqual(["https://a.png", "https://b.png"])
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it("emits a directive per used (image, label) identity", () => {
|
|
642
|
+
const result = buildImagePrompt({
|
|
643
|
+
prompt: "{image:1:dragon} fighting {image:2:dragon} in {image:1:background}",
|
|
644
|
+
provider: "nano-banana",
|
|
645
|
+
connectedReferences: [
|
|
646
|
+
{ id: "a", defaultName: "Image 1", source: "manual", url: "https://a.png" },
|
|
647
|
+
{ id: "b", defaultName: "Image 2", source: "manual", url: "https://b.png" },
|
|
648
|
+
],
|
|
649
|
+
})
|
|
650
|
+
expect(result.prompt).toContain("- Image 1 (dragon) — match exactly.")
|
|
651
|
+
expect(result.prompt).toContain("- Image 2 (dragon) — match exactly.")
|
|
652
|
+
expect(result.prompt).toContain("- Image 1 (background) — use as the background/setting.")
|
|
653
|
+
expect(result.prompt).toContain("Image 1 (dragon) fighting Image 2 (dragon) in Image 1 (background)")
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it("separates directives from the user prompt and uses the composition prefix", () => {
|
|
657
|
+
const result = buildImagePrompt({
|
|
658
|
+
prompt: "{image:2:object} is riding {image:1:object} in {image:1:background}",
|
|
659
|
+
provider: "nano-banana",
|
|
660
|
+
connectedReferences: [
|
|
661
|
+
{ id: "a", defaultName: "Image 1", source: "manual", url: "https://a.png" },
|
|
662
|
+
{ id: "b", defaultName: "Image 2", source: "manual", url: "https://b.png" },
|
|
663
|
+
],
|
|
664
|
+
})
|
|
665
|
+
expect(result.prompt).toMatch(/^Use these references for the output image:\n/)
|
|
666
|
+
expect(result.prompt).toContain("Compose them naturally into a single image: Image 2 (object)")
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it("identityMeta override switches a single identity to strict", () => {
|
|
670
|
+
const result = buildImagePrompt({
|
|
671
|
+
prompt: "{image:1:object}",
|
|
672
|
+
provider: "nano-banana",
|
|
673
|
+
connectedReferences: [
|
|
674
|
+
{ id: "a", defaultName: "Image 1", source: "manual", url: "https://a.png" },
|
|
675
|
+
],
|
|
676
|
+
identityMeta: [
|
|
677
|
+
{ imageIndex: 1, label: "object", fidelity: "strict" },
|
|
678
|
+
],
|
|
679
|
+
})
|
|
680
|
+
expect(result.prompt).toContain("- Image 1 (object) — match exactly. Maintain perfect likeness.")
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it("uses parenthetical form for proper-noun labels (no article needed)", () => {
|
|
684
|
+
const result = buildImagePrompt({
|
|
685
|
+
prompt: "{image:1:Danny} smiling",
|
|
686
|
+
provider: "nano-banana",
|
|
687
|
+
connectedReferences: [
|
|
688
|
+
{ id: "a", defaultName: "Image 1", source: "manual", url: "https://a.png" },
|
|
689
|
+
],
|
|
690
|
+
})
|
|
691
|
+
expect(result.prompt).toContain("- Image 1 (Danny) — match exactly.")
|
|
692
|
+
expect(result.prompt).toContain("Image 1 (Danny) smiling")
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
it("uses scene-setting verb for background-style labels", () => {
|
|
696
|
+
const result = buildImagePrompt({
|
|
697
|
+
prompt: "{image:1:setting}",
|
|
698
|
+
provider: "nano-banana",
|
|
699
|
+
connectedReferences: [
|
|
700
|
+
{ id: "a", defaultName: "Image 1", source: "manual", url: "https://a.png" },
|
|
701
|
+
],
|
|
702
|
+
})
|
|
703
|
+
expect(result.prompt).toContain("use as the background/setting.")
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
it("uses 'apply' verb for texture/style labels", () => {
|
|
707
|
+
const result = buildImagePrompt({
|
|
708
|
+
prompt: "{image:1:style}",
|
|
709
|
+
provider: "nano-banana",
|
|
710
|
+
connectedReferences: [
|
|
711
|
+
{ id: "a", defaultName: "Image 1", source: "manual", url: "https://a.png" },
|
|
712
|
+
],
|
|
713
|
+
})
|
|
714
|
+
expect(result.prompt).toContain("apply this style.")
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
it("identityMeta custom replaces the directive with customText", () => {
|
|
718
|
+
const result = buildImagePrompt({
|
|
719
|
+
prompt: "{image:1:wall}",
|
|
720
|
+
provider: "nano-banana",
|
|
721
|
+
connectedReferences: [
|
|
722
|
+
{ id: "a", defaultName: "Image 1", source: "manual", url: "https://a.png" },
|
|
723
|
+
],
|
|
724
|
+
identityMeta: [
|
|
725
|
+
{ imageIndex: 1, label: "wall", fidelity: "custom", customText: "Use only the texture, ignore everything else." },
|
|
726
|
+
],
|
|
727
|
+
})
|
|
728
|
+
expect(result.prompt).toContain("Use only the texture, ignore everything else.")
|
|
729
|
+
expect(result.prompt).not.toContain("Render the wall")
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it("inlines the upstream description in the directive when the ref is mentioned", () => {
|
|
733
|
+
// Note: character refs are now mention-only (no {image:N} expansion).
|
|
734
|
+
// Use a wired-face ref to test the {image:N:label} expansion path.
|
|
735
|
+
const result = buildImagePrompt({
|
|
736
|
+
prompt: "{image:1:Sarah} on a beach",
|
|
737
|
+
provider: "nano-banana",
|
|
738
|
+
connectedReferences: [
|
|
739
|
+
{ id: "c1", defaultName: "Sarah", source: "wired-face", description: "tall, red hair", url: "https://s.png" },
|
|
740
|
+
],
|
|
741
|
+
})
|
|
742
|
+
// Description is folded into the parenthetical with an em-dash separator
|
|
743
|
+
expect(result.prompt).toContain("Image 1 (Sarah — tall, red hair)")
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it("expands {image:1} (no label) to numeric position", () => {
|
|
747
|
+
// Note: character refs are now mention-only. Use a non-character source.
|
|
748
|
+
const result = buildImagePrompt({
|
|
749
|
+
prompt: "{image:1} smiling",
|
|
750
|
+
provider: "nano-banana",
|
|
751
|
+
connectedReferences: [
|
|
752
|
+
{ id: "c1", defaultName: "Sarah", source: "wired-face", url: "https://s.png" },
|
|
753
|
+
],
|
|
754
|
+
})
|
|
755
|
+
expect(result.prompt).toContain("Image 1 smiling")
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it("expands {image:1:label} to parenthetical numeric form", () => {
|
|
759
|
+
const result = buildImagePrompt({
|
|
760
|
+
prompt: "{image:1:dragon} roaring",
|
|
761
|
+
provider: "nano-banana",
|
|
762
|
+
connectedReferences: [
|
|
763
|
+
{ id: "a", defaultName: "Image 1", source: "manual", url: "https://a.png" },
|
|
764
|
+
],
|
|
765
|
+
})
|
|
766
|
+
expect(result.prompt).toContain("Image 1 (dragon) roaring")
|
|
767
|
+
expect(result.prompt).not.toContain("{image:1:dragon}")
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it("sends URLs in connected-reference order", () => {
|
|
771
|
+
const result = buildImagePrompt({
|
|
772
|
+
prompt: "scene",
|
|
773
|
+
provider: "nano-banana",
|
|
774
|
+
connectedReferences: [
|
|
775
|
+
{ id: "a", defaultName: "A", source: "manual", url: "https://a.png" },
|
|
776
|
+
{ id: "b", defaultName: "B", source: "manual", url: "https://b.png" },
|
|
777
|
+
],
|
|
778
|
+
})
|
|
779
|
+
expect(result.referenceImageUrls).toEqual(["https://a.png", "https://b.png"])
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
it("ignores characterDefs when connectedReferences is provided", () => {
|
|
783
|
+
const result = buildImagePrompt({
|
|
784
|
+
prompt: "scene",
|
|
785
|
+
provider: "nano-banana",
|
|
786
|
+
connectedReferences: [
|
|
787
|
+
{ id: "c1", defaultName: "Sarah", source: "wired-character", url: "https://s.png" },
|
|
788
|
+
],
|
|
789
|
+
characterDefs: [
|
|
790
|
+
{ id: "ignored", name: "Ignored", type: "description", description: "should not appear" },
|
|
791
|
+
],
|
|
792
|
+
})
|
|
793
|
+
expect(result.prompt).not.toContain("should not appear")
|
|
794
|
+
expect(result.prompt).not.toContain("Ignored")
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it("does NOT emit a default-label block when image is mentioned with a different label", () => {
|
|
798
|
+
const result = buildImagePrompt({
|
|
799
|
+
prompt: "{image:1:background}",
|
|
800
|
+
provider: "nano-banana",
|
|
801
|
+
connectedReferences: [
|
|
802
|
+
{ id: "a", defaultName: "Image 1", source: "manual", url: "https://a.png" },
|
|
803
|
+
],
|
|
804
|
+
})
|
|
805
|
+
// Only the "background" identity should produce a directive — no extra "object" default.
|
|
806
|
+
expect(result.prompt).toContain("- Image 1 (background) — use as the background/setting.")
|
|
807
|
+
expect(result.prompt).not.toContain("Image 1 (object)")
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it("filters URLs for providers that don't support reference images", () => {
|
|
811
|
+
const result = buildImagePrompt({
|
|
812
|
+
prompt: "scene",
|
|
813
|
+
provider: "imagen4", // pure T2I, no i2i sibling — refs unusable
|
|
814
|
+
connectedReferences: [
|
|
815
|
+
{ id: "c1", defaultName: "Sarah", source: "wired-character", url: "https://s.png" },
|
|
816
|
+
],
|
|
817
|
+
})
|
|
818
|
+
expect(result.referenceImageUrls).toBeUndefined()
|
|
819
|
+
})
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
// ---------------------------------------------------------------------------
|
|
823
|
+
// expandImagePositionRefs
|
|
824
|
+
// ---------------------------------------------------------------------------
|
|
825
|
+
describe("expandImagePositionRefs", () => {
|
|
826
|
+
it("replaces {image:1} with numeric position", () => {
|
|
827
|
+
expect(expandImagePositionRefs("{image:1} shows", 2)).toBe("Image 1 shows")
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it("replaces multiple valid tokens", () => {
|
|
831
|
+
expect(expandImagePositionRefs("{image:1} and {image:2}", 2)).toBe(
|
|
832
|
+
"Image 1 and Image 2",
|
|
833
|
+
)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
it("leaves {image:3} unchanged when count is 2", () => {
|
|
837
|
+
expect(expandImagePositionRefs("{image:3}", 2)).toBe("{image:3}")
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
it("leaves {image:0} unchanged (1-indexed)", () => {
|
|
841
|
+
expect(expandImagePositionRefs("{image:0}", 3)).toBe("{image:0}")
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it("returns unchanged prompt with no tokens", () => {
|
|
845
|
+
expect(expandImagePositionRefs("no tokens here", 5)).toBe("no tokens here")
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
it("returns unchanged prompt when count is 0", () => {
|
|
849
|
+
expect(expandImagePositionRefs("{image:1}", 0)).toBe("{image:1}")
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
it("is case-insensitive", () => {
|
|
853
|
+
expect(expandImagePositionRefs("{IMAGE:1}", 1)).toBe("Image 1")
|
|
854
|
+
expect(expandImagePositionRefs("{Image:2}", 3)).toBe("Image 2")
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
it("handles adjacent tokens", () => {
|
|
858
|
+
expect(expandImagePositionRefs("{image:1}{image:2}", 2)).toBe(
|
|
859
|
+
"Image 1Image 2",
|
|
860
|
+
)
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
it("uses names when provided (legacy override)", () => {
|
|
864
|
+
expect(expandImagePositionRefs("{image:1} smiling", 2, ["Sarah", "Bob"])).toBe("Sarah smiling")
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
it("falls back to numeric position when name is missing", () => {
|
|
868
|
+
expect(expandImagePositionRefs("{image:2} appears", 2, ["Sarah"])).toBe("Image 2 appears")
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
it("preserves out-of-range tokens even with names", () => {
|
|
872
|
+
expect(expandImagePositionRefs("{image:5}", 2, ["Sarah", "Bob"])).toBe("{image:5}")
|
|
873
|
+
})
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
// ---------------------------------------------------------------------------
|
|
877
|
+
// buildScenePrompt
|
|
878
|
+
// ---------------------------------------------------------------------------
|
|
879
|
+
describe("buildScenePrompt", () => {
|
|
880
|
+
describe("minimal scene", () => {
|
|
881
|
+
it("produces shot + angle for minimal input", () => {
|
|
882
|
+
const scene = makeScene()
|
|
883
|
+
const result = buildScenePrompt(scene, [])
|
|
884
|
+
expect(result).toBe("MEDIUM SHOT, eye level")
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
it("uses fallback for unknown shot type", () => {
|
|
888
|
+
const scene = makeScene({ shotType: "unknown-shot" })
|
|
889
|
+
const result = buildScenePrompt(scene, [])
|
|
890
|
+
// Falls back to "MEDIUM SHOT" since unknown key is not in SHOT_LABELS
|
|
891
|
+
expect(result).toBe("MEDIUM SHOT, eye level")
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
it("uses fallback for unknown camera angle", () => {
|
|
895
|
+
const scene = makeScene({ cameraAngle: "unknown-angle" })
|
|
896
|
+
const result = buildScenePrompt(scene, [])
|
|
897
|
+
expect(result).toBe("MEDIUM SHOT, eye level")
|
|
898
|
+
})
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
describe("shot types and angles", () => {
|
|
902
|
+
it("renders close-up + low angle", () => {
|
|
903
|
+
const scene = makeScene({ shotType: "close-up", cameraAngle: "low-angle" })
|
|
904
|
+
const result = buildScenePrompt(scene, [])
|
|
905
|
+
expect(result).toBe("CLOSE-UP, low angle")
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
it("renders extreme wide + birds eye", () => {
|
|
909
|
+
const scene = makeScene({ shotType: "extreme-wide", cameraAngle: "birds-eye" })
|
|
910
|
+
const result = buildScenePrompt(scene, [])
|
|
911
|
+
expect(result).toContain("EXTREME WIDE SHOT")
|
|
912
|
+
expect(result).toContain("bird's eye view")
|
|
913
|
+
})
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
describe("aspect ratio", () => {
|
|
917
|
+
it("omits default aspect ratio (16:9)", () => {
|
|
918
|
+
const scene = makeScene({ aspectRatio: "16:9" })
|
|
919
|
+
const result = buildScenePrompt(scene, [])
|
|
920
|
+
expect(result).not.toContain("composition")
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
it("includes non-default aspect ratio", () => {
|
|
924
|
+
const scene = makeScene({ aspectRatio: "9:16" })
|
|
925
|
+
const result = buildScenePrompt(scene, [])
|
|
926
|
+
expect(result).toContain("vertical portrait composition")
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
it("includes 1:1 aspect ratio", () => {
|
|
930
|
+
const scene = makeScene({ aspectRatio: "1:1" })
|
|
931
|
+
const result = buildScenePrompt(scene, [])
|
|
932
|
+
expect(result).toContain("square composition")
|
|
933
|
+
})
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
describe("characters", () => {
|
|
937
|
+
it("includes character name and description from assets", () => {
|
|
938
|
+
const scene = makeScene({
|
|
939
|
+
characters: [{ assetId: "c1", mood: "", action: "" }],
|
|
940
|
+
})
|
|
941
|
+
const assets: CharacterDef[] = [
|
|
942
|
+
makeCharDef({ id: "c1", name: "Alice", description: "brave warrior" }),
|
|
943
|
+
]
|
|
944
|
+
const result = buildScenePrompt(scene, assets)
|
|
945
|
+
expect(result).toContain("of Alice, brave warrior")
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it("includes character mood when present", () => {
|
|
949
|
+
const scene = makeScene({
|
|
950
|
+
characters: [{ assetId: "c1", mood: "angry", action: "" }],
|
|
951
|
+
})
|
|
952
|
+
const assets = [makeCharDef({ id: "c1", name: "Alice" })]
|
|
953
|
+
const result = buildScenePrompt(scene, assets)
|
|
954
|
+
expect(result).toContain("angry")
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
it("includes character action when present", () => {
|
|
958
|
+
const scene = makeScene({
|
|
959
|
+
characters: [{ assetId: "c1", mood: "", action: "running" }],
|
|
960
|
+
})
|
|
961
|
+
const assets = [makeCharDef({ id: "c1", name: "Alice" })]
|
|
962
|
+
const result = buildScenePrompt(scene, assets)
|
|
963
|
+
expect(result).toContain("running")
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
it("includes character position in frame", () => {
|
|
967
|
+
const scene = makeScene({
|
|
968
|
+
characters: [{ assetId: "c1", mood: "", action: "", positionInFrame: "left" }],
|
|
969
|
+
})
|
|
970
|
+
const assets = [makeCharDef({ id: "c1", name: "Alice" })]
|
|
971
|
+
const result = buildScenePrompt(scene, assets)
|
|
972
|
+
expect(result).toContain("(left)")
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
it("joins multiple characters with 'and'", () => {
|
|
976
|
+
const scene = makeScene({
|
|
977
|
+
characters: [
|
|
978
|
+
{ assetId: "c1", mood: "", action: "" },
|
|
979
|
+
{ assetId: "c2", mood: "", action: "" },
|
|
980
|
+
],
|
|
981
|
+
})
|
|
982
|
+
const assets = [
|
|
983
|
+
makeCharDef({ id: "c1", name: "Alice" }),
|
|
984
|
+
makeCharDef({ id: "c2", name: "Bob" }),
|
|
985
|
+
]
|
|
986
|
+
const result = buildScenePrompt(scene, assets)
|
|
987
|
+
expect(result).toContain("Alice")
|
|
988
|
+
expect(result).toContain("and")
|
|
989
|
+
expect(result).toContain("Bob")
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
it("falls back to 'a figure' when asset is not found", () => {
|
|
993
|
+
const scene = makeScene({
|
|
994
|
+
characters: [{ assetId: "missing", mood: "", action: "" }],
|
|
995
|
+
})
|
|
996
|
+
const result = buildScenePrompt(scene, [])
|
|
997
|
+
expect(result).toContain("a figure")
|
|
998
|
+
})
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
describe("locations", () => {
|
|
1002
|
+
it("includes location name", () => {
|
|
1003
|
+
const scene = makeScene({
|
|
1004
|
+
locations: [{ assetId: "loc1", name: "Dark Forest" }],
|
|
1005
|
+
})
|
|
1006
|
+
const result = buildScenePrompt(scene, [])
|
|
1007
|
+
expect(result).toContain("in Dark Forest")
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
it("falls back to asset description for location name", () => {
|
|
1011
|
+
const scene = makeScene({
|
|
1012
|
+
locations: [{ assetId: "loc1" }],
|
|
1013
|
+
})
|
|
1014
|
+
const assets = [
|
|
1015
|
+
makeCharDef({ id: "loc1", name: "Forest", description: "a dark enchanted forest" }),
|
|
1016
|
+
]
|
|
1017
|
+
const result = buildScenePrompt(scene, assets)
|
|
1018
|
+
expect(result).toContain("in a dark enchanted forest")
|
|
1019
|
+
})
|
|
1020
|
+
|
|
1021
|
+
it("falls back to asset name when no description", () => {
|
|
1022
|
+
const scene = makeScene({
|
|
1023
|
+
locations: [{ assetId: "loc1" }],
|
|
1024
|
+
})
|
|
1025
|
+
const assets = [makeCharDef({ id: "loc1", name: "Forest", description: undefined })]
|
|
1026
|
+
const result = buildScenePrompt(scene, assets)
|
|
1027
|
+
expect(result).toContain("in Forest")
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
it("falls back to 'location' when nothing is available", () => {
|
|
1031
|
+
const scene = makeScene({
|
|
1032
|
+
locations: [{ assetId: "missing" }],
|
|
1033
|
+
})
|
|
1034
|
+
const result = buildScenePrompt(scene, [])
|
|
1035
|
+
expect(result).toContain("in location")
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
it("includes non-default env conditions in location parenthetical", () => {
|
|
1039
|
+
const scene = makeScene({
|
|
1040
|
+
locations: [{ assetId: "loc1", name: "Beach", timeOfDay: "sunset", weather: "foggy", lighting: "dramatic" }],
|
|
1041
|
+
})
|
|
1042
|
+
const result = buildScenePrompt(scene, [])
|
|
1043
|
+
expect(result).toContain("Beach (sunset light, foggy, dramatic lighting)")
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
it("omits default env values (noon, clear, natural) from location", () => {
|
|
1047
|
+
const scene = makeScene({
|
|
1048
|
+
locations: [{ assetId: "loc1", name: "Beach", timeOfDay: "noon", weather: "clear", lighting: "natural" }],
|
|
1049
|
+
})
|
|
1050
|
+
const result = buildScenePrompt(scene, [])
|
|
1051
|
+
expect(result).toContain("in Beach")
|
|
1052
|
+
expect(result).not.toContain("noon")
|
|
1053
|
+
expect(result).not.toContain("clear")
|
|
1054
|
+
expect(result).not.toContain("natural")
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
it("inherits scene-level env values when location-level are absent", () => {
|
|
1058
|
+
const scene = makeScene({
|
|
1059
|
+
timeOfDay: "dusk",
|
|
1060
|
+
weather: "rainy",
|
|
1061
|
+
lighting: "natural",
|
|
1062
|
+
locations: [{ assetId: "loc1", name: "Park" }],
|
|
1063
|
+
})
|
|
1064
|
+
const result = buildScenePrompt(scene, [])
|
|
1065
|
+
expect(result).toContain("Park (dusk light, rainy)")
|
|
1066
|
+
expect(result).not.toContain("natural")
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
it("joins multiple locations with 'and'", () => {
|
|
1070
|
+
const scene = makeScene({
|
|
1071
|
+
locations: [
|
|
1072
|
+
{ assetId: "l1", name: "Forest" },
|
|
1073
|
+
{ assetId: "l2", name: "River" },
|
|
1074
|
+
],
|
|
1075
|
+
})
|
|
1076
|
+
const result = buildScenePrompt(scene, [])
|
|
1077
|
+
expect(result).toContain("Forest")
|
|
1078
|
+
expect(result).toContain("and")
|
|
1079
|
+
expect(result).toContain("River")
|
|
1080
|
+
})
|
|
1081
|
+
})
|
|
1082
|
+
|
|
1083
|
+
describe("environment without locations", () => {
|
|
1084
|
+
it("omits default env values (noon, clear, natural)", () => {
|
|
1085
|
+
const scene = makeScene()
|
|
1086
|
+
const result = buildScenePrompt(scene, [])
|
|
1087
|
+
expect(result).not.toContain("noon")
|
|
1088
|
+
expect(result).not.toContain("clear")
|
|
1089
|
+
expect(result).not.toContain("natural")
|
|
1090
|
+
})
|
|
1091
|
+
|
|
1092
|
+
it("includes non-default timeOfDay", () => {
|
|
1093
|
+
const scene = makeScene({ timeOfDay: "dawn" })
|
|
1094
|
+
const result = buildScenePrompt(scene, [])
|
|
1095
|
+
expect(result).toContain("dawn light")
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it("includes non-default weather", () => {
|
|
1099
|
+
const scene = makeScene({ weather: "stormy" })
|
|
1100
|
+
const result = buildScenePrompt(scene, [])
|
|
1101
|
+
expect(result).toContain("stormy")
|
|
1102
|
+
})
|
|
1103
|
+
|
|
1104
|
+
it("includes non-default lighting", () => {
|
|
1105
|
+
const scene = makeScene({ lighting: "dramatic" })
|
|
1106
|
+
const result = buildScenePrompt(scene, [])
|
|
1107
|
+
expect(result).toContain("dramatic lighting")
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
it("includes multiple non-default env values", () => {
|
|
1111
|
+
const scene = makeScene({ timeOfDay: "dusk", weather: "snowy", lighting: "neon" })
|
|
1112
|
+
const result = buildScenePrompt(scene, [])
|
|
1113
|
+
expect(result).toContain("dusk light")
|
|
1114
|
+
expect(result).toContain("snowy")
|
|
1115
|
+
expect(result).toContain("neon lighting")
|
|
1116
|
+
})
|
|
1117
|
+
})
|
|
1118
|
+
|
|
1119
|
+
describe("objects", () => {
|
|
1120
|
+
it("includes object description", () => {
|
|
1121
|
+
const scene = makeScene({
|
|
1122
|
+
objects: [{ assetId: "o1", description: "a glowing sword" }],
|
|
1123
|
+
})
|
|
1124
|
+
const result = buildScenePrompt(scene, [])
|
|
1125
|
+
expect(result).toContain("with a glowing sword")
|
|
1126
|
+
})
|
|
1127
|
+
|
|
1128
|
+
it("falls back to asset name when no object description", () => {
|
|
1129
|
+
const scene = makeScene({
|
|
1130
|
+
objects: [{ assetId: "o1" }],
|
|
1131
|
+
})
|
|
1132
|
+
const assets = [makeCharDef({ id: "o1", name: "Sword" })]
|
|
1133
|
+
const result = buildScenePrompt(scene, assets)
|
|
1134
|
+
expect(result).toContain("with Sword")
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
it("falls back to 'object' when nothing is available", () => {
|
|
1138
|
+
const scene = makeScene({
|
|
1139
|
+
objects: [{ assetId: "missing" }],
|
|
1140
|
+
})
|
|
1141
|
+
const result = buildScenePrompt(scene, [])
|
|
1142
|
+
expect(result).toContain("with object")
|
|
1143
|
+
})
|
|
1144
|
+
|
|
1145
|
+
it("joins multiple objects with commas", () => {
|
|
1146
|
+
const scene = makeScene({
|
|
1147
|
+
objects: [
|
|
1148
|
+
{ assetId: "o1", description: "sword" },
|
|
1149
|
+
{ assetId: "o2", description: "shield" },
|
|
1150
|
+
],
|
|
1151
|
+
})
|
|
1152
|
+
const result = buildScenePrompt(scene, [])
|
|
1153
|
+
expect(result).toContain("with sword, shield")
|
|
1154
|
+
})
|
|
1155
|
+
})
|
|
1156
|
+
|
|
1157
|
+
describe("mood, visual style, camera movement", () => {
|
|
1158
|
+
it("includes mood as atmosphere", () => {
|
|
1159
|
+
const scene = makeScene({ mood: ["tense", "dark"] })
|
|
1160
|
+
const result = buildScenePrompt(scene, [])
|
|
1161
|
+
expect(result).toContain("tense, dark atmosphere")
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
it("includes visual style", () => {
|
|
1165
|
+
const scene = makeScene({ visualStyle: "noir" })
|
|
1166
|
+
const result = buildScenePrompt(scene, [])
|
|
1167
|
+
expect(result).toContain("noir style")
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
it("includes non-static camera movement", () => {
|
|
1171
|
+
const scene = makeScene({ cameraMovement: "dolly" })
|
|
1172
|
+
const result = buildScenePrompt(scene, [])
|
|
1173
|
+
expect(result).toContain("dolly shot")
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
it("omits static camera movement", () => {
|
|
1177
|
+
const scene = makeScene({ cameraMovement: "static" })
|
|
1178
|
+
const result = buildScenePrompt(scene, [])
|
|
1179
|
+
expect(result).not.toContain("static camera")
|
|
1180
|
+
})
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
describe("low-priority parts", () => {
|
|
1184
|
+
it("includes non-default depth of field", () => {
|
|
1185
|
+
const scene = makeScene({ depthOfField: "shallow" })
|
|
1186
|
+
const result = buildScenePrompt(scene, [])
|
|
1187
|
+
expect(result).toContain("shallow depth of field")
|
|
1188
|
+
})
|
|
1189
|
+
|
|
1190
|
+
it("omits default (medium) depth of field", () => {
|
|
1191
|
+
const scene = makeScene({ depthOfField: "medium" })
|
|
1192
|
+
const result = buildScenePrompt(scene, [])
|
|
1193
|
+
expect(result).not.toContain("depth of field")
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
it("includes non-default lens type", () => {
|
|
1197
|
+
const scene = makeScene({ lensType: "wide-angle" })
|
|
1198
|
+
const result = buildScenePrompt(scene, [])
|
|
1199
|
+
expect(result).toContain("wide-angle lens")
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
it("omits default (normal) lens type", () => {
|
|
1203
|
+
const scene = makeScene({ lensType: "normal" })
|
|
1204
|
+
const result = buildScenePrompt(scene, [])
|
|
1205
|
+
expect(result).not.toContain("lens")
|
|
1206
|
+
})
|
|
1207
|
+
|
|
1208
|
+
it("includes color palette", () => {
|
|
1209
|
+
const scene = makeScene({ colorPalette: ["red", "gold"] })
|
|
1210
|
+
const result = buildScenePrompt(scene, [])
|
|
1211
|
+
expect(result).toContain("red, gold color palette")
|
|
1212
|
+
})
|
|
1213
|
+
|
|
1214
|
+
it("includes summary", () => {
|
|
1215
|
+
const scene = makeScene({ summary: "An epic battle unfolds" })
|
|
1216
|
+
const result = buildScenePrompt(scene, [])
|
|
1217
|
+
expect(result).toContain("An epic battle unfolds")
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
it("includes dialogue", () => {
|
|
1221
|
+
const scene = makeScene({
|
|
1222
|
+
dialogue: [
|
|
1223
|
+
{ characterName: "Alice", text: "I will fight!", emotion: "determined" },
|
|
1224
|
+
],
|
|
1225
|
+
})
|
|
1226
|
+
const result = buildScenePrompt(scene, [])
|
|
1227
|
+
expect(result).toContain('dialogue:')
|
|
1228
|
+
expect(result).toContain('Alice (determined): "I will fight!"')
|
|
1229
|
+
})
|
|
1230
|
+
|
|
1231
|
+
it("skips dialogue entries with empty text", () => {
|
|
1232
|
+
const scene = makeScene({
|
|
1233
|
+
dialogue: [
|
|
1234
|
+
{ characterName: "Alice", text: " " },
|
|
1235
|
+
{ characterName: "Bob", text: "Hello" },
|
|
1236
|
+
],
|
|
1237
|
+
})
|
|
1238
|
+
const result = buildScenePrompt(scene, [])
|
|
1239
|
+
expect(result).not.toContain("Alice")
|
|
1240
|
+
expect(result).toContain("Bob")
|
|
1241
|
+
})
|
|
1242
|
+
|
|
1243
|
+
it("includes director notes", () => {
|
|
1244
|
+
const scene = makeScene({ directorNotes: "Focus on tension" })
|
|
1245
|
+
const result = buildScenePrompt(scene, [])
|
|
1246
|
+
expect(result).toContain("Focus on tension")
|
|
1247
|
+
})
|
|
1248
|
+
})
|
|
1249
|
+
|
|
1250
|
+
describe("progressive truncation", () => {
|
|
1251
|
+
it("drops low-priority parts first when exceeding safe length", () => {
|
|
1252
|
+
// Create a scene with low-priority parts that push over SCENE_PROMPT_SAFE_LENGTH (1800)
|
|
1253
|
+
const longNotes = "N".repeat(800)
|
|
1254
|
+
const longDialogue = "D".repeat(800)
|
|
1255
|
+
const scene = makeScene({
|
|
1256
|
+
summary: "S".repeat(400),
|
|
1257
|
+
directorNotes: longNotes,
|
|
1258
|
+
dialogue: [{ characterName: "A", text: longDialogue }],
|
|
1259
|
+
colorPalette: ["red", "blue"],
|
|
1260
|
+
depthOfField: "shallow",
|
|
1261
|
+
})
|
|
1262
|
+
const result = buildScenePrompt(scene, [])
|
|
1263
|
+
// The result should be within bounds
|
|
1264
|
+
expect(result.length).toBeLessThanOrEqual(SCENE_PROMPT_MAX_LENGTH)
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
it("drops medium-priority parts after all low are dropped", () => {
|
|
1268
|
+
// Stuff high+medium so that even without low, it still exceeds safe length
|
|
1269
|
+
const longSummary = "S".repeat(1000)
|
|
1270
|
+
const scene = makeScene({
|
|
1271
|
+
summary: longSummary,
|
|
1272
|
+
mood: ["tense"],
|
|
1273
|
+
visualStyle: "highly detailed cinematic ultra-realistic",
|
|
1274
|
+
})
|
|
1275
|
+
const result = buildScenePrompt(scene, [])
|
|
1276
|
+
expect(result.length).toBeLessThanOrEqual(SCENE_PROMPT_MAX_LENGTH)
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
it("hard-truncates if high parts alone exceed max length", () => {
|
|
1280
|
+
// Characters with very long descriptions can push high parts over the limit
|
|
1281
|
+
const longCharDescs = Array.from({ length: 20 }, (_, i) => ({
|
|
1282
|
+
assetId: `c${i}`,
|
|
1283
|
+
mood: "intense",
|
|
1284
|
+
action: "running quickly through the dense mysterious fog",
|
|
1285
|
+
positionInFrame: "center" as const,
|
|
1286
|
+
}))
|
|
1287
|
+
const assets = longCharDescs.map((_, i) =>
|
|
1288
|
+
makeCharDef({ id: `c${i}`, name: `Character${i}`, description: "X".repeat(100) }),
|
|
1289
|
+
)
|
|
1290
|
+
const scene = makeScene({ characters: longCharDescs })
|
|
1291
|
+
const result = buildScenePrompt(scene, assets)
|
|
1292
|
+
expect(result.length).toBeLessThanOrEqual(SCENE_PROMPT_MAX_LENGTH)
|
|
1293
|
+
expect(result.endsWith("...")).toBe(true)
|
|
1294
|
+
})
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
describe("forDisplay option", () => {
|
|
1298
|
+
it("disables truncation when forDisplay is true", () => {
|
|
1299
|
+
const longNotes = "N".repeat(2000)
|
|
1300
|
+
const scene = makeScene({
|
|
1301
|
+
directorNotes: longNotes,
|
|
1302
|
+
summary: "S".repeat(500),
|
|
1303
|
+
depthOfField: "shallow",
|
|
1304
|
+
lensType: "telephoto",
|
|
1305
|
+
colorPalette: ["red", "blue", "green"],
|
|
1306
|
+
})
|
|
1307
|
+
const result = buildScenePrompt(scene, [], { forDisplay: true })
|
|
1308
|
+
// Should contain the full director notes without truncation
|
|
1309
|
+
expect(result).toContain(longNotes)
|
|
1310
|
+
// Can exceed the max length
|
|
1311
|
+
expect(result.length).toBeGreaterThan(SCENE_PROMPT_MAX_LENGTH)
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1314
|
+
it("does not truncate character descriptions when forDisplay is true", () => {
|
|
1315
|
+
const longDesc = "D".repeat(300)
|
|
1316
|
+
const scene = makeScene({
|
|
1317
|
+
characters: [{ assetId: "c1", mood: "", action: "" }],
|
|
1318
|
+
})
|
|
1319
|
+
const assets = [makeCharDef({ id: "c1", name: "Hero", description: longDesc })]
|
|
1320
|
+
const result = buildScenePrompt(scene, assets, { forDisplay: true })
|
|
1321
|
+
expect(result).toContain(longDesc)
|
|
1322
|
+
})
|
|
1323
|
+
})
|
|
1324
|
+
|
|
1325
|
+
describe("comprehensive scene", () => {
|
|
1326
|
+
it("produces a well-formed prompt with all parts populated", () => {
|
|
1327
|
+
const scene = makeScene({
|
|
1328
|
+
shotType: "close-up",
|
|
1329
|
+
cameraAngle: "low-angle",
|
|
1330
|
+
aspectRatio: "9:16",
|
|
1331
|
+
characters: [
|
|
1332
|
+
{ assetId: "c1", mood: "determined", action: "swinging a sword", positionInFrame: "center" },
|
|
1333
|
+
],
|
|
1334
|
+
locations: [{ assetId: "l1", name: "Castle Courtyard", timeOfDay: "dusk", weather: "foggy", lighting: "torchlight" }],
|
|
1335
|
+
objects: [{ assetId: "o1", description: "ancient shield" }],
|
|
1336
|
+
mood: ["epic", "intense"],
|
|
1337
|
+
visualStyle: "fantasy oil painting",
|
|
1338
|
+
depthOfField: "shallow",
|
|
1339
|
+
lensType: "telephoto",
|
|
1340
|
+
cameraMovement: "tracking",
|
|
1341
|
+
colorPalette: ["gold", "crimson"],
|
|
1342
|
+
summary: "A warrior stands her ground",
|
|
1343
|
+
dialogue: [{ characterName: "Alice", text: "Stand back!", emotion: "fierce" }],
|
|
1344
|
+
directorNotes: "Emphasize the scale of the castle",
|
|
1345
|
+
})
|
|
1346
|
+
const assets = [
|
|
1347
|
+
makeCharDef({ id: "c1", name: "Alice", description: "a fierce warrior in golden armor" }),
|
|
1348
|
+
]
|
|
1349
|
+
const result = buildScenePrompt(scene, assets)
|
|
1350
|
+
|
|
1351
|
+
expect(result).toContain("CLOSE-UP")
|
|
1352
|
+
expect(result).toContain("low angle")
|
|
1353
|
+
expect(result).toContain("vertical portrait composition")
|
|
1354
|
+
expect(result).toContain("Alice")
|
|
1355
|
+
expect(result).toContain("Castle Courtyard")
|
|
1356
|
+
expect(result).toContain("ancient shield")
|
|
1357
|
+
expect(result).toContain("epic, intense atmosphere")
|
|
1358
|
+
expect(result).toContain("fantasy oil painting style")
|
|
1359
|
+
expect(result.length).toBeLessThanOrEqual(SCENE_PROMPT_MAX_LENGTH)
|
|
1360
|
+
})
|
|
1361
|
+
})
|
|
1362
|
+
})
|
|
1363
|
+
|
|
1364
|
+
// ---------------------------------------------------------------------------
|
|
1365
|
+
// Phase 2 #1: Location canonical-description injection (Location Studio
|
|
1366
|
+
// design doc). When a wired-location ref attaches to a downstream node, the
|
|
1367
|
+
// directive bullet should pick up the location's canonicalDescription so the
|
|
1368
|
+
// model sees the rich description without the user typing it. Per-ref
|
|
1369
|
+
// description (the user-typed Description on the location node) still wins
|
|
1370
|
+
// when present, and `suppressedCanonicalLocationIds` filters the canonical
|
|
1371
|
+
// fallback (mirrors `suppressedCanonicalCharacterIds`).
|
|
1372
|
+
// ---------------------------------------------------------------------------
|
|
1373
|
+
|
|
1374
|
+
describe("buildImagePrompt — location canonical-description injection", () => {
|
|
1375
|
+
it("includes locationCanonicalDescription in the directive bullet for wired-location refs", () => {
|
|
1376
|
+
const result = buildImagePrompt({
|
|
1377
|
+
prompt: "A hero stands in front of {image:1:location}",
|
|
1378
|
+
provider: "nano-banana",
|
|
1379
|
+
connectedReferences: [
|
|
1380
|
+
{
|
|
1381
|
+
id: "loc_1",
|
|
1382
|
+
defaultName: "Old Library",
|
|
1383
|
+
source: "wired-location",
|
|
1384
|
+
url: "https://r2/old-library.png",
|
|
1385
|
+
locationCanonicalDescription: "A dimly-lit Victorian library with leather-bound books and brass fixtures",
|
|
1386
|
+
locationSlug: "old-library",
|
|
1387
|
+
},
|
|
1388
|
+
],
|
|
1389
|
+
})
|
|
1390
|
+
expect(result.prompt).toContain(
|
|
1391
|
+
"Image 1 (location — A dimly-lit Victorian library with leather-bound books and brass fixtures)",
|
|
1392
|
+
)
|
|
1393
|
+
})
|
|
1394
|
+
|
|
1395
|
+
it("per-ref description wins over canonical when both are set", () => {
|
|
1396
|
+
const result = buildImagePrompt({
|
|
1397
|
+
prompt: "A hero in front of {image:1:location}",
|
|
1398
|
+
provider: "nano-banana",
|
|
1399
|
+
connectedReferences: [
|
|
1400
|
+
{
|
|
1401
|
+
id: "loc_1",
|
|
1402
|
+
defaultName: "Old Library",
|
|
1403
|
+
source: "wired-location",
|
|
1404
|
+
description: "Stately library at dusk",
|
|
1405
|
+
url: "https://r2/old-library.png",
|
|
1406
|
+
locationCanonicalDescription: "A dimly-lit Victorian library with leather-bound books",
|
|
1407
|
+
locationSlug: "old-library",
|
|
1408
|
+
},
|
|
1409
|
+
],
|
|
1410
|
+
})
|
|
1411
|
+
expect(result.prompt).toContain("Image 1 (location — Stately library at dusk)")
|
|
1412
|
+
expect(result.prompt).not.toContain("dimly-lit Victorian library")
|
|
1413
|
+
})
|
|
1414
|
+
|
|
1415
|
+
it("suppressedCanonicalLocationIds drops the canonical fallback", () => {
|
|
1416
|
+
const result = buildImagePrompt({
|
|
1417
|
+
prompt: "A hero in front of {image:1:location}",
|
|
1418
|
+
provider: "nano-banana",
|
|
1419
|
+
connectedReferences: [
|
|
1420
|
+
{
|
|
1421
|
+
id: "loc_1",
|
|
1422
|
+
defaultName: "Old Library",
|
|
1423
|
+
source: "wired-location",
|
|
1424
|
+
url: "https://r2/old-library.png",
|
|
1425
|
+
locationCanonicalDescription: "A dimly-lit Victorian library",
|
|
1426
|
+
locationSlug: "old-library",
|
|
1427
|
+
},
|
|
1428
|
+
],
|
|
1429
|
+
suppressedCanonicalLocationIds: ["old-library"],
|
|
1430
|
+
})
|
|
1431
|
+
// No canonical text; bare positional directive instead.
|
|
1432
|
+
expect(result.prompt).not.toContain("Victorian library")
|
|
1433
|
+
expect(result.prompt).toContain("Image 1 (location)")
|
|
1434
|
+
})
|
|
1435
|
+
|
|
1436
|
+
it("does NOT inject locationCanonicalDescription for non-location refs", () => {
|
|
1437
|
+
const result = buildImagePrompt({
|
|
1438
|
+
prompt: "A hero in front of {image:1:object}",
|
|
1439
|
+
provider: "nano-banana",
|
|
1440
|
+
connectedReferences: [
|
|
1441
|
+
{
|
|
1442
|
+
id: "obj_1",
|
|
1443
|
+
defaultName: "Sword",
|
|
1444
|
+
source: "wired-object",
|
|
1445
|
+
url: "https://r2/sword.png",
|
|
1446
|
+
// Even if we accidentally populate this on a non-location ref, the
|
|
1447
|
+
// directive builder should ignore it.
|
|
1448
|
+
locationCanonicalDescription: "should-be-ignored",
|
|
1449
|
+
locationSlug: "sword",
|
|
1450
|
+
},
|
|
1451
|
+
],
|
|
1452
|
+
})
|
|
1453
|
+
expect(result.prompt).not.toContain("should-be-ignored")
|
|
1454
|
+
})
|
|
1455
|
+
|
|
1456
|
+
it("no-op when locationCanonicalDescription is empty string", () => {
|
|
1457
|
+
const result = buildImagePrompt({
|
|
1458
|
+
prompt: "A hero in front of {image:1:location}",
|
|
1459
|
+
provider: "nano-banana",
|
|
1460
|
+
connectedReferences: [
|
|
1461
|
+
{
|
|
1462
|
+
id: "loc_1",
|
|
1463
|
+
defaultName: "Old Library",
|
|
1464
|
+
source: "wired-location",
|
|
1465
|
+
url: "https://r2/old-library.png",
|
|
1466
|
+
locationCanonicalDescription: " ",
|
|
1467
|
+
locationSlug: "old-library",
|
|
1468
|
+
},
|
|
1469
|
+
],
|
|
1470
|
+
})
|
|
1471
|
+
expect(result.prompt).toContain("Image 1 (location)")
|
|
1472
|
+
})
|
|
1473
|
+
})
|
|
1474
|
+
|
|
1475
|
+
// ---------------------------------------------------------------------------
|
|
1476
|
+
// wired-creature — a bound Creature (animal/beast/monster) auto-attaches like
|
|
1477
|
+
// an object but its directive reads as a LIVING subject: the creature/animal
|
|
1478
|
+
// identity lock (anatomy, markings, coloration) instead of the prop verb.
|
|
1479
|
+
// ---------------------------------------------------------------------------
|
|
1480
|
+
|
|
1481
|
+
describe("buildImagePrompt — wired-creature refs", () => {
|
|
1482
|
+
it("auto-attaches a wired creature with a creature-labeled fallback directive (zero typing)", () => {
|
|
1483
|
+
const result = buildImagePrompt({
|
|
1484
|
+
prompt: "A knight rides through the forest",
|
|
1485
|
+
provider: "nano-banana",
|
|
1486
|
+
connectedReferences: [
|
|
1487
|
+
{
|
|
1488
|
+
id: "cre_1",
|
|
1489
|
+
defaultName: "Ember",
|
|
1490
|
+
source: "wired-creature",
|
|
1491
|
+
url: "https://r2/ember-dragon.png",
|
|
1492
|
+
},
|
|
1493
|
+
],
|
|
1494
|
+
})
|
|
1495
|
+
expect(result.referenceImageUrls).toContain("https://r2/ember-dragon.png")
|
|
1496
|
+
expect(result.prompt).toContain("Image 1 (creature)")
|
|
1497
|
+
expect(result.prompt).toContain("creature/animal subject")
|
|
1498
|
+
expect(result.prompt).toContain("anatomy, markings, coloration")
|
|
1499
|
+
})
|
|
1500
|
+
|
|
1501
|
+
it("folds the creature's description into the directive subject", () => {
|
|
1502
|
+
const result = buildImagePrompt({
|
|
1503
|
+
prompt: "A knight rides through the forest",
|
|
1504
|
+
provider: "nano-banana",
|
|
1505
|
+
connectedReferences: [
|
|
1506
|
+
{
|
|
1507
|
+
id: "cre_1",
|
|
1508
|
+
defaultName: "Ember",
|
|
1509
|
+
source: "wired-creature",
|
|
1510
|
+
description: "a small red dragon with golden eyes",
|
|
1511
|
+
url: "https://r2/ember-dragon.png",
|
|
1512
|
+
},
|
|
1513
|
+
],
|
|
1514
|
+
})
|
|
1515
|
+
expect(result.prompt).toContain("Image 1 (creature — a small red dragon with golden eyes)")
|
|
1516
|
+
expect(result.prompt).toContain("creature/animal subject")
|
|
1517
|
+
})
|
|
1518
|
+
|
|
1519
|
+
it("{image:N:creature} token routes through the creature identity lock", () => {
|
|
1520
|
+
const result = buildImagePrompt({
|
|
1521
|
+
prompt: "A portrait of {image:1:creature} sleeping by the fire",
|
|
1522
|
+
provider: "nano-banana",
|
|
1523
|
+
connectedReferences: [
|
|
1524
|
+
{
|
|
1525
|
+
id: "cre_1",
|
|
1526
|
+
defaultName: "Whiskers",
|
|
1527
|
+
source: "wired-creature",
|
|
1528
|
+
url: "https://r2/whiskers-cat.png",
|
|
1529
|
+
},
|
|
1530
|
+
],
|
|
1531
|
+
})
|
|
1532
|
+
expect(result.prompt).toContain("Image 1 (creature)")
|
|
1533
|
+
expect(result.prompt).toContain("creature/animal subject")
|
|
1534
|
+
expect(result.prompt).toContain("Maintain perfect likeness (anatomy, markings, coloration, distinctive features)")
|
|
1535
|
+
})
|
|
1536
|
+
|
|
1537
|
+
it("an 'animal' label from the user also hits the creature lock; 'loose' opts out", () => {
|
|
1538
|
+
const locked = buildImagePrompt({
|
|
1539
|
+
prompt: "Show {image:1:animal} running",
|
|
1540
|
+
provider: "nano-banana",
|
|
1541
|
+
connectedReferences: [
|
|
1542
|
+
{ id: "c1", defaultName: "Rex", source: "wired-creature", url: "https://r2/rex.png" },
|
|
1543
|
+
],
|
|
1544
|
+
})
|
|
1545
|
+
expect(locked.prompt).toContain("creature/animal subject")
|
|
1546
|
+
|
|
1547
|
+
const loose = buildImagePrompt({
|
|
1548
|
+
prompt: "Show {image:1:creature} running",
|
|
1549
|
+
provider: "nano-banana",
|
|
1550
|
+
connectedReferences: [
|
|
1551
|
+
{ id: "c1", defaultName: "Rex", source: "wired-creature", url: "https://r2/rex.png" },
|
|
1552
|
+
],
|
|
1553
|
+
identityMeta: [{ imageIndex: 1, label: "creature", fidelity: "loose" }],
|
|
1554
|
+
})
|
|
1555
|
+
expect(loose.prompt).toContain("use loosely as inspiration")
|
|
1556
|
+
expect(loose.prompt).not.toContain("creature/animal subject")
|
|
1557
|
+
})
|
|
1558
|
+
})
|
|
1559
|
+
|
|
1560
|
+
// ---------------------------------------------------------------------------
|
|
1561
|
+
// Phase 2 #2 (slice 2b): @location:1:variant mention syntax resolution. The
|
|
1562
|
+
// resolver matches tokens against pre-expanded canonical + per-variant
|
|
1563
|
+
// `wired-location` ConnectedReferences, emits a "Use these locations:" header
|
|
1564
|
+
// + per-location bullet, and replaces the @-token inline.
|
|
1565
|
+
// ---------------------------------------------------------------------------
|
|
1566
|
+
|
|
1567
|
+
describe("buildImagePrompt — @location mention resolution", () => {
|
|
1568
|
+
const CANONICAL_REF = {
|
|
1569
|
+
id: "loc_1",
|
|
1570
|
+
defaultName: "Old Library",
|
|
1571
|
+
source: "wired-location" as const,
|
|
1572
|
+
url: "https://r2/old-library.png",
|
|
1573
|
+
locationCanonicalDescription: "A dimly-lit Victorian library with leather-bound books",
|
|
1574
|
+
locationSlug: "old-library",
|
|
1575
|
+
}
|
|
1576
|
+
const RAIN_VARIANT_REF = {
|
|
1577
|
+
id: "loc_1_weather_rain",
|
|
1578
|
+
defaultName: "Old Library / Rain",
|
|
1579
|
+
source: "wired-location" as const,
|
|
1580
|
+
url: "https://r2/old-library-rain.png",
|
|
1581
|
+
locationCanonicalDescription: "A dimly-lit Victorian library with leather-bound books",
|
|
1582
|
+
locationSlug: "old-library",
|
|
1583
|
+
locationVariantBucket: "weather",
|
|
1584
|
+
locationVariantSlug: "rain",
|
|
1585
|
+
locationVariantDisplayName: "rain",
|
|
1586
|
+
}
|
|
1587
|
+
const NEON_LIGHTING_REF = {
|
|
1588
|
+
id: "loc_1_lighting_neon",
|
|
1589
|
+
defaultName: "Old Library / Neon",
|
|
1590
|
+
source: "wired-location" as const,
|
|
1591
|
+
url: "https://r2/old-library-neon.png",
|
|
1592
|
+
locationSlug: "old-library",
|
|
1593
|
+
locationVariantBucket: "lighting",
|
|
1594
|
+
locationVariantSlug: "neon",
|
|
1595
|
+
locationVariantDisplayName: "neon",
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
it("resolves @old-library:1 canonical mention with identical-mode bullet", () => {
|
|
1599
|
+
const result = buildImagePrompt({
|
|
1600
|
+
prompt: "Hero stands in @old-library:1.",
|
|
1601
|
+
provider: "nano-banana",
|
|
1602
|
+
connectedReferences: [CANONICAL_REF],
|
|
1603
|
+
})
|
|
1604
|
+
expect(result.prompt).toContain("Use these locations:")
|
|
1605
|
+
expect(result.prompt).toContain(
|
|
1606
|
+
"Image 1 (Old Library) — A dimly-lit Victorian library with leather-bound books",
|
|
1607
|
+
)
|
|
1608
|
+
expect(result.prompt).toContain("Hero stands in Old Library.")
|
|
1609
|
+
expect(result.referenceImageUrls).toContain("https://r2/old-library.png")
|
|
1610
|
+
})
|
|
1611
|
+
|
|
1612
|
+
it("resolves @old-library:1:weather/rain to the variant URL", () => {
|
|
1613
|
+
const result = buildImagePrompt({
|
|
1614
|
+
prompt: "Hero stands in @old-library:1:weather/rain.",
|
|
1615
|
+
provider: "nano-banana",
|
|
1616
|
+
connectedReferences: [CANONICAL_REF, RAIN_VARIANT_REF],
|
|
1617
|
+
})
|
|
1618
|
+
expect(result.referenceImageUrls).toContain("https://r2/old-library-rain.png")
|
|
1619
|
+
expect(result.referenceImageUrls).not.toContain("https://r2/old-library.png")
|
|
1620
|
+
expect(result.prompt).toContain("(in this image: rain)")
|
|
1621
|
+
})
|
|
1622
|
+
|
|
1623
|
+
it("style mode emits the style directive, no canonical description", () => {
|
|
1624
|
+
const result = buildImagePrompt({
|
|
1625
|
+
prompt: "Set in @old-library:1:style.",
|
|
1626
|
+
provider: "nano-banana",
|
|
1627
|
+
connectedReferences: [CANONICAL_REF],
|
|
1628
|
+
})
|
|
1629
|
+
expect(result.prompt).toContain(
|
|
1630
|
+
"Image 1 (Old Library). use as a style / mood reference — borrow color, lighting, and atmosphere.",
|
|
1631
|
+
)
|
|
1632
|
+
// No canonical desc in style mode.
|
|
1633
|
+
expect(result.prompt).not.toContain("Victorian library")
|
|
1634
|
+
})
|
|
1635
|
+
|
|
1636
|
+
it("layout mode emits the layout directive", () => {
|
|
1637
|
+
const result = buildImagePrompt({
|
|
1638
|
+
prompt: "Compose like @old-library:1:layout.",
|
|
1639
|
+
provider: "nano-banana",
|
|
1640
|
+
connectedReferences: [CANONICAL_REF],
|
|
1641
|
+
})
|
|
1642
|
+
expect(result.prompt).toContain(
|
|
1643
|
+
"use as a compositional layout / camera framing reference.",
|
|
1644
|
+
)
|
|
1645
|
+
})
|
|
1646
|
+
|
|
1647
|
+
it("none mode attaches the URL but emits NO bullet and substitutes 'Image N'", () => {
|
|
1648
|
+
const result = buildImagePrompt({
|
|
1649
|
+
prompt: "Hero stands in @old-library:1:none.",
|
|
1650
|
+
provider: "nano-banana",
|
|
1651
|
+
connectedReferences: [CANONICAL_REF],
|
|
1652
|
+
})
|
|
1653
|
+
expect(result.referenceImageUrls).toContain("https://r2/old-library.png")
|
|
1654
|
+
// No bullet was emitted → no "Use these locations:" header.
|
|
1655
|
+
expect(result.prompt).not.toContain("Use these locations:")
|
|
1656
|
+
expect(result.prompt).toContain("Hero stands in Image 1.")
|
|
1657
|
+
})
|
|
1658
|
+
|
|
1659
|
+
it("multiple mentions of the same location emit ONE bullet", () => {
|
|
1660
|
+
const result = buildImagePrompt({
|
|
1661
|
+
prompt: "Set in @old-library:1, then @old-library:1:lighting/neon.",
|
|
1662
|
+
provider: "nano-banana",
|
|
1663
|
+
connectedReferences: [CANONICAL_REF, NEON_LIGHTING_REF],
|
|
1664
|
+
})
|
|
1665
|
+
const headerCount = (result.prompt.match(/^- Image \d \(Old Library/gm) ?? []).length
|
|
1666
|
+
expect(headerCount).toBe(1)
|
|
1667
|
+
// Both URLs still attach.
|
|
1668
|
+
expect(result.referenceImageUrls).toContain("https://r2/old-library.png")
|
|
1669
|
+
expect(result.referenceImageUrls).toContain("https://r2/old-library-neon.png")
|
|
1670
|
+
})
|
|
1671
|
+
|
|
1672
|
+
it("falls through to literal text for unknown variant slug (no match)", () => {
|
|
1673
|
+
const result = buildImagePrompt({
|
|
1674
|
+
prompt: "Set in @old-library:1:weather/blizzard.",
|
|
1675
|
+
provider: "nano-banana",
|
|
1676
|
+
connectedReferences: [CANONICAL_REF, RAIN_VARIANT_REF],
|
|
1677
|
+
})
|
|
1678
|
+
// No bullet, token stays as typed.
|
|
1679
|
+
expect(result.prompt).not.toContain("Use these locations:")
|
|
1680
|
+
expect(result.prompt).toContain("@old-library:1:weather/blizzard")
|
|
1681
|
+
})
|
|
1682
|
+
|
|
1683
|
+
it("does NOT trip on @-mentions whose slug isn't a known location", () => {
|
|
1684
|
+
const result = buildImagePrompt({
|
|
1685
|
+
prompt: "Hero @kira:1 stands in @somewhere:2.",
|
|
1686
|
+
provider: "nano-banana",
|
|
1687
|
+
connectedReferences: [CANONICAL_REF],
|
|
1688
|
+
})
|
|
1689
|
+
// Neither @kira nor @somewhere maps to old-library; no location resolution
|
|
1690
|
+
// fires for them.
|
|
1691
|
+
expect(result.prompt).not.toContain("Use these locations:")
|
|
1692
|
+
})
|
|
1693
|
+
})
|
|
1694
|
+
|
|
1695
|
+
// ---------------------------------------------------------------------------
|
|
1696
|
+
// Phase 2 #3: kind-tagged reference-photo subject-line annotation. When a
|
|
1697
|
+
// wired-location ConnectedReference carries a `locationReferencePhotoKind`,
|
|
1698
|
+
// the directive subject's parenthetical should pick up the kind's
|
|
1699
|
+
// human-friendly label so the model sees the photo's role inline.
|
|
1700
|
+
// ---------------------------------------------------------------------------
|
|
1701
|
+
|
|
1702
|
+
describe("buildIdentityDirectives — locationReferencePhotoKind subject-line annotation", () => {
|
|
1703
|
+
it("annotates the subject with 'wide-angle reference' for kind=wide", () => {
|
|
1704
|
+
// The {image:N:label} token regex only accepts [a-zA-Z0-9_-] for the
|
|
1705
|
+
// label, so the test uses the hyphenated form "old-library".
|
|
1706
|
+
const result = buildImagePrompt({
|
|
1707
|
+
prompt: "A hero stands in front of {image:1:old-library}",
|
|
1708
|
+
provider: "nano-banana",
|
|
1709
|
+
connectedReferences: [
|
|
1710
|
+
{
|
|
1711
|
+
id: "loc_1_refphoto_wide_0",
|
|
1712
|
+
defaultName: "Old Library (wide-angle reference)",
|
|
1713
|
+
source: "wired-location",
|
|
1714
|
+
url: "https://r2/old-library-wide.png",
|
|
1715
|
+
locationSlug: "old-library",
|
|
1716
|
+
locationReferencePhotoKind: "wide",
|
|
1717
|
+
},
|
|
1718
|
+
],
|
|
1719
|
+
})
|
|
1720
|
+
expect(result.prompt).toContain("Image 1 (old-library — wide-angle reference)")
|
|
1721
|
+
})
|
|
1722
|
+
|
|
1723
|
+
it("maps every kind to its expected label substring", () => {
|
|
1724
|
+
const cases: ReadonlyArray<{ kind: "wide" | "interior" | "exterior" | "detail" | "moodBoard" | "other"; substring: string }> = [
|
|
1725
|
+
{ kind: "wide", substring: "wide-angle reference" },
|
|
1726
|
+
{ kind: "interior", substring: "interior reference" },
|
|
1727
|
+
{ kind: "exterior", substring: "exterior reference" },
|
|
1728
|
+
{ kind: "detail", substring: "detail reference" },
|
|
1729
|
+
{ kind: "moodBoard", substring: "mood-board reference" },
|
|
1730
|
+
{ kind: "other", substring: "reference" },
|
|
1731
|
+
]
|
|
1732
|
+
for (const { kind, substring } of cases) {
|
|
1733
|
+
const result = buildImagePrompt({
|
|
1734
|
+
prompt: "A hero stands in front of {image:1:old-library}",
|
|
1735
|
+
provider: "nano-banana",
|
|
1736
|
+
connectedReferences: [
|
|
1737
|
+
{
|
|
1738
|
+
id: `loc_1_refphoto_${kind}_0`,
|
|
1739
|
+
defaultName: `Old Library (${kind})`,
|
|
1740
|
+
source: "wired-location",
|
|
1741
|
+
url: `https://r2/old-library-${kind}.png`,
|
|
1742
|
+
locationSlug: "old-library",
|
|
1743
|
+
locationReferencePhotoKind: kind,
|
|
1744
|
+
},
|
|
1745
|
+
],
|
|
1746
|
+
})
|
|
1747
|
+
expect(result.prompt).toContain(`old-library — ${substring}`)
|
|
1748
|
+
}
|
|
1749
|
+
})
|
|
1750
|
+
|
|
1751
|
+
it("does NOT annotate when locationReferencePhotoKind is unset (negative test)", () => {
|
|
1752
|
+
// Plain wired-location canonical ref — should render as
|
|
1753
|
+
// "Image 1 (old-library)" with no kind annotation in the parenthetical.
|
|
1754
|
+
const result = buildImagePrompt({
|
|
1755
|
+
prompt: "A hero stands in front of {image:1:old-library}",
|
|
1756
|
+
provider: "nano-banana",
|
|
1757
|
+
connectedReferences: [
|
|
1758
|
+
{
|
|
1759
|
+
id: "loc_1",
|
|
1760
|
+
defaultName: "Old Library",
|
|
1761
|
+
source: "wired-location",
|
|
1762
|
+
url: "https://r2/old-library.png",
|
|
1763
|
+
locationSlug: "old-library",
|
|
1764
|
+
},
|
|
1765
|
+
],
|
|
1766
|
+
})
|
|
1767
|
+
expect(result.prompt).toContain("Image 1 (old-library)")
|
|
1768
|
+
expect(result.prompt).not.toContain("wide-angle reference")
|
|
1769
|
+
expect(result.prompt).not.toContain("interior reference")
|
|
1770
|
+
expect(result.prompt).not.toContain("mood-board reference")
|
|
1771
|
+
})
|
|
1772
|
+
})
|
|
1773
|
+
|