@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,809 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { buildImagePrompt } from "../prompt-builder.js"
|
|
3
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
4
|
+
|
|
5
|
+
const kiraCanonical: ConnectedReference = {
|
|
6
|
+
id: "ref-kira",
|
|
7
|
+
defaultName: "Kira",
|
|
8
|
+
source: "wired-character",
|
|
9
|
+
description: "young woman with warm smile",
|
|
10
|
+
url: "https://r2/kira-portrait.png",
|
|
11
|
+
characterSlug: "kira",
|
|
12
|
+
variantSlug: undefined,
|
|
13
|
+
characterCanonicalDescription: "young woman, brown eyes, auburn shoulder-length hair, athletic build",
|
|
14
|
+
variantDescription: null,
|
|
15
|
+
variantDisplayName: "canonical",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const kiraSmile: ConnectedReference = {
|
|
19
|
+
id: "ref-kira-smile",
|
|
20
|
+
defaultName: "Kira / smile",
|
|
21
|
+
source: "wired-character",
|
|
22
|
+
description: "warm closed-mouth smile",
|
|
23
|
+
url: "https://r2/kira-smile.png",
|
|
24
|
+
characterSlug: "kira",
|
|
25
|
+
variantSlug: "smile",
|
|
26
|
+
characterCanonicalDescription: "young woman, brown eyes, auburn shoulder-length hair, athletic build",
|
|
27
|
+
variantDescription: "warm closed-mouth smile, eyes slightly crinkled",
|
|
28
|
+
variantDisplayName: "smile",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const kiraWalking: ConnectedReference = {
|
|
32
|
+
id: "ref-kira-walking",
|
|
33
|
+
defaultName: "Kira / walking",
|
|
34
|
+
source: "wired-character",
|
|
35
|
+
description: "walking pose",
|
|
36
|
+
url: "https://r2/kira-walking.png",
|
|
37
|
+
characterSlug: "kira",
|
|
38
|
+
variantSlug: "walking",
|
|
39
|
+
characterCanonicalDescription: "young woman, brown eyes, auburn shoulder-length hair, athletic build",
|
|
40
|
+
variantDescription: "mid-stride walking pose",
|
|
41
|
+
variantDisplayName: "walking",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const adamCanonical: ConnectedReference = {
|
|
45
|
+
id: "ref-adam",
|
|
46
|
+
defaultName: "Adam",
|
|
47
|
+
source: "wired-character",
|
|
48
|
+
description: "older man",
|
|
49
|
+
url: "https://r2/adam-portrait.png",
|
|
50
|
+
characterSlug: "adam",
|
|
51
|
+
variantSlug: undefined,
|
|
52
|
+
characterCanonicalDescription: "older man, grey hair, broad shoulders",
|
|
53
|
+
variantDescription: null,
|
|
54
|
+
variantDisplayName: "canonical",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("buildImagePrompt with @-mentions", () => {
|
|
58
|
+
it("resolves @kira:1:smile to URL + appends variant description + Image 1 (Kira) subject", () => {
|
|
59
|
+
const result = buildImagePrompt({
|
|
60
|
+
prompt: "make her dance, @kira:1:smile",
|
|
61
|
+
provider: "nano-banana-pro",
|
|
62
|
+
connectedReferences: [kiraCanonical, kiraSmile],
|
|
63
|
+
})
|
|
64
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-smile.png")
|
|
65
|
+
expect(result.prompt).toContain("warm closed-mouth smile")
|
|
66
|
+
expect(result.prompt).not.toMatch(/@kira:1:smile\b/)
|
|
67
|
+
// Numeric index in the directive matches the user-typed slug.
|
|
68
|
+
expect(result.prompt).toContain("Image 1 (Kira)")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("resolves @kira:1 (no variant) to canonical entry", () => {
|
|
72
|
+
const result = buildImagePrompt({
|
|
73
|
+
prompt: "feature @kira:1 prominently",
|
|
74
|
+
provider: "nano-banana-pro",
|
|
75
|
+
connectedReferences: [kiraCanonical, kiraSmile],
|
|
76
|
+
})
|
|
77
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-portrait.png")
|
|
78
|
+
expect(result.prompt).toContain("auburn shoulder-length hair")
|
|
79
|
+
expect(result.prompt).toContain("Image 1 (Kira)")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("dedupes canonical description when character appears in multiple tokens", () => {
|
|
83
|
+
const result = buildImagePrompt({
|
|
84
|
+
prompt: "@kira:1 looks at her own @kira:2:smile",
|
|
85
|
+
provider: "nano-banana-pro",
|
|
86
|
+
connectedReferences: [kiraCanonical, kiraSmile],
|
|
87
|
+
})
|
|
88
|
+
const matches = (result.prompt.match(/auburn shoulder-length hair/g) || []).length
|
|
89
|
+
expect(matches).toBe(1)
|
|
90
|
+
expect(result.referenceImageUrls).toEqual(
|
|
91
|
+
expect.arrayContaining(["https://r2/kira-portrait.png", "https://r2/kira-smile.png"]),
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("leaves @<slug>:<index> as literal when no character match", () => {
|
|
96
|
+
const result = buildImagePrompt({
|
|
97
|
+
prompt: "make @unknown:1 wave",
|
|
98
|
+
provider: "nano-banana-pro",
|
|
99
|
+
connectedReferences: [kiraCanonical],
|
|
100
|
+
})
|
|
101
|
+
expect(result.prompt).toContain("@unknown:1")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Fix A (default fallback): wired character with no @-mention → canonical attached.
|
|
105
|
+
it("ATTACHES canonical URL when character is wired but not @-mentioned", () => {
|
|
106
|
+
const result = buildImagePrompt({
|
|
107
|
+
prompt: "just a dragon flying",
|
|
108
|
+
provider: "nano-banana-pro",
|
|
109
|
+
connectedReferences: [kiraCanonical, kiraSmile],
|
|
110
|
+
})
|
|
111
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-portrait.png")
|
|
112
|
+
// No variant auto-attaches.
|
|
113
|
+
expect(result.referenceImageUrls ?? []).not.toContain("https://r2/kira-smile.png")
|
|
114
|
+
// Strong directive for the canonical fallback. The numeric index now ALSO
|
|
115
|
+
// gets prefixed on canonical fallback bullets so the model can correlate
|
|
116
|
+
// the ref position with the named character ("Image 1 in the input array
|
|
117
|
+
// is Kira"). Position is 1 because this is the only ref and there are no
|
|
118
|
+
// pre-existing refs.
|
|
119
|
+
expect(result.prompt).toContain("Image 1 (Kira)")
|
|
120
|
+
expect(result.prompt).toContain("auburn shoulder-length hair")
|
|
121
|
+
expect(result.prompt).toContain("The subject must remain exactly the same person")
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("attaches ONLY mentioned variant URL when character IS @-mentioned (no canonical fallback)", () => {
|
|
125
|
+
const result = buildImagePrompt({
|
|
126
|
+
prompt: "show @kira:1:smile dancing",
|
|
127
|
+
provider: "nano-banana-pro",
|
|
128
|
+
connectedReferences: [kiraCanonical, kiraSmile],
|
|
129
|
+
})
|
|
130
|
+
expect(result.referenceImageUrls).toEqual(["https://r2/kira-smile.png"])
|
|
131
|
+
// No canonical attached because the character was explicitly mentioned.
|
|
132
|
+
expect(result.referenceImageUrls).not.toContain("https://r2/kira-portrait.png")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("two wired characters, only one @-mentioned → mentioned-one gets variant, unmentioned gets canonical", () => {
|
|
136
|
+
const result = buildImagePrompt({
|
|
137
|
+
prompt: "@kira:1:smile and her friend talking",
|
|
138
|
+
provider: "nano-banana-pro",
|
|
139
|
+
connectedReferences: [kiraCanonical, kiraSmile, adamCanonical],
|
|
140
|
+
})
|
|
141
|
+
// Kira (mentioned) → smile variant only.
|
|
142
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-smile.png")
|
|
143
|
+
expect(result.referenceImageUrls).not.toContain("https://r2/kira-portrait.png")
|
|
144
|
+
// Adam (not mentioned) → canonical fallback.
|
|
145
|
+
expect(result.referenceImageUrls).toContain("https://r2/adam-portrait.png")
|
|
146
|
+
// Both characters get strong directives with numeric prefixes in the same
|
|
147
|
+
// "Use these characters:" block. Kira is mention #1 so emits at Image 1;
|
|
148
|
+
// Adam's canonical fallback lands at Image 2 (1 mention URL + 1 fallback).
|
|
149
|
+
expect(result.prompt).toContain("Use these characters:")
|
|
150
|
+
expect(result.prompt).toContain("Image 1 (Kira)")
|
|
151
|
+
expect(result.prompt).toContain("Image 2 (Adam)")
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("multiple mentions of same character pick the right variant per token", () => {
|
|
155
|
+
const result = buildImagePrompt({
|
|
156
|
+
prompt: "@kira:1:smile waves, then @kira:2:walking",
|
|
157
|
+
provider: "nano-banana-pro",
|
|
158
|
+
connectedReferences: [kiraCanonical, kiraSmile, kiraWalking],
|
|
159
|
+
})
|
|
160
|
+
expect(result.referenceImageUrls).toEqual(
|
|
161
|
+
expect.arrayContaining(["https://r2/kira-smile.png", "https://r2/kira-walking.png"]),
|
|
162
|
+
)
|
|
163
|
+
// Canonical NOT attached because the character was @-mentioned.
|
|
164
|
+
expect(result.referenceImageUrls).not.toContain("https://r2/kira-portrait.png")
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("attaches non-character refs (manual / wired-image) even without @-mention", () => {
|
|
168
|
+
const manualRef: ConnectedReference = {
|
|
169
|
+
id: "ref-manual-1",
|
|
170
|
+
defaultName: "Image 1",
|
|
171
|
+
source: "manual",
|
|
172
|
+
url: "https://r2/manual.png",
|
|
173
|
+
}
|
|
174
|
+
const result = buildImagePrompt({
|
|
175
|
+
prompt: "a scene with the object",
|
|
176
|
+
provider: "nano-banana-pro",
|
|
177
|
+
connectedReferences: [kiraCanonical, manualRef],
|
|
178
|
+
})
|
|
179
|
+
// Manual ref auto-attaches; character ref ALSO auto-attaches now via the
|
|
180
|
+
// default-fallback (per Fix A).
|
|
181
|
+
expect(result.referenceImageUrls).toContain("https://r2/manual.png")
|
|
182
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-portrait.png")
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("emits a strengthened identity directive when a character is mentioned", () => {
|
|
186
|
+
const result = buildImagePrompt({
|
|
187
|
+
prompt: "@kira:1 dancing in the rain",
|
|
188
|
+
provider: "nano-banana-pro",
|
|
189
|
+
connectedReferences: [kiraCanonical],
|
|
190
|
+
})
|
|
191
|
+
expect(result.prompt).toContain("The subject must remain exactly the same person")
|
|
192
|
+
expect(result.prompt).toContain("preserve 100% facial identity")
|
|
193
|
+
expect(result.prompt).toContain("Image 1 (Kira)")
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("strengthens directive for non-character refs labeled 'person' (numeric index)", () => {
|
|
197
|
+
const personRef: ConnectedReference = {
|
|
198
|
+
id: "ref-face",
|
|
199
|
+
defaultName: "Sarah",
|
|
200
|
+
source: "wired-face",
|
|
201
|
+
url: "https://r2/sarah.png",
|
|
202
|
+
description: "tall, red hair",
|
|
203
|
+
}
|
|
204
|
+
const result = buildImagePrompt({
|
|
205
|
+
prompt: "{image:1:person} smiling",
|
|
206
|
+
provider: "nano-banana-pro",
|
|
207
|
+
connectedReferences: [personRef],
|
|
208
|
+
})
|
|
209
|
+
// Per-image directive folds in identity-preservation language; numeric index.
|
|
210
|
+
expect(result.prompt).toContain("Image 1 (person")
|
|
211
|
+
expect(result.prompt).toContain("match exactly. Maintain perfect likeness (face, body proportions, distinctive features)")
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// Reproduces the user-reported scenario from the bug report: a character
|
|
215
|
+
// "shira" with two expression variants (smile, laughing), the user typing a
|
|
216
|
+
// prompt that mentions both variants. The fix in `execute-node.ts` ensures
|
|
217
|
+
// image-to-image / modify-image build a full `connectedReferences` array so
|
|
218
|
+
// this scenario actually reaches Phase 0 (it was previously bypassed). This
|
|
219
|
+
// test guards the underlying Phase 0 resolution path that the executor now
|
|
220
|
+
// routes through.
|
|
221
|
+
// -------------------------------------------------------------------------
|
|
222
|
+
// Usage-mode tests — per-mention slug override + character-node default
|
|
223
|
+
// propagation through ConnectedReference.defaultUsageMode.
|
|
224
|
+
// -------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
it("@kira:1:face emits face-only directive (no canonical description)", () => {
|
|
227
|
+
const result = buildImagePrompt({
|
|
228
|
+
prompt: "@kira:1:face waving",
|
|
229
|
+
provider: "nano-banana-pro",
|
|
230
|
+
connectedReferences: [kiraCanonical],
|
|
231
|
+
})
|
|
232
|
+
expect(result.prompt).toContain("Take ONLY the facial features")
|
|
233
|
+
expect(result.prompt).toContain("Adopt clothing, hair styling, and posture")
|
|
234
|
+
// Face-only deliberately drops the canonical-description prefix so the
|
|
235
|
+
// model isn't anchored to body proportions when only the face is wanted.
|
|
236
|
+
expect(result.prompt).not.toContain("auburn shoulder-length hair")
|
|
237
|
+
expect(result.prompt).toContain("Image 1 (Kira)")
|
|
238
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-portrait.png")
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("@kira:1:smile:face combines variant URL with face-only directive", () => {
|
|
242
|
+
const result = buildImagePrompt({
|
|
243
|
+
prompt: "show @kira:1:smile:face dancing",
|
|
244
|
+
provider: "nano-banana-pro",
|
|
245
|
+
connectedReferences: [kiraCanonical, kiraSmile],
|
|
246
|
+
})
|
|
247
|
+
expect(result.referenceImageUrls).toEqual(["https://r2/kira-smile.png"])
|
|
248
|
+
expect(result.prompt).toContain("Take ONLY the facial features")
|
|
249
|
+
expect(result.prompt).toContain("Image 1 (Kira)")
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("@kira:1:style emits style-only directive", () => {
|
|
253
|
+
const result = buildImagePrompt({
|
|
254
|
+
prompt: "redo @kira:1:style as a comic panel",
|
|
255
|
+
provider: "nano-banana-pro",
|
|
256
|
+
connectedReferences: [kiraCanonical],
|
|
257
|
+
})
|
|
258
|
+
expect(result.prompt).toContain("Take only the visual style and tone")
|
|
259
|
+
expect(result.prompt).not.toContain("auburn shoulder-length hair")
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it("@kira:1:emotion emits emotion-only directive", () => {
|
|
263
|
+
const result = buildImagePrompt({
|
|
264
|
+
prompt: "@kira:1:emotion expressed by another character",
|
|
265
|
+
provider: "nano-banana-pro",
|
|
266
|
+
connectedReferences: [kiraCanonical],
|
|
267
|
+
})
|
|
268
|
+
expect(result.prompt).toContain("Take only the emotional expression")
|
|
269
|
+
expect(result.prompt).toContain("transfer only the emotional cue")
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("@kira:1:face-pose emits face + pose directive AND keeps canonical description", () => {
|
|
273
|
+
const result = buildImagePrompt({
|
|
274
|
+
prompt: "@kira:1:face-pose in a different outfit",
|
|
275
|
+
provider: "nano-banana-pro",
|
|
276
|
+
connectedReferences: [kiraCanonical],
|
|
277
|
+
})
|
|
278
|
+
expect(result.prompt).toContain("Take the facial features AND body pose")
|
|
279
|
+
expect(result.prompt).toContain("Adopt clothing and styling")
|
|
280
|
+
// face-pose deliberately keeps the canonical description so the model
|
|
281
|
+
// holds the underlying identity while reposing.
|
|
282
|
+
expect(result.prompt).toContain("auburn shoulder-length hair")
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it("falls back to character node's defaultUsageMode when slug has no mode", () => {
|
|
286
|
+
const kiraFace: ConnectedReference = {
|
|
287
|
+
...kiraCanonical,
|
|
288
|
+
defaultUsageMode: "face",
|
|
289
|
+
}
|
|
290
|
+
const result = buildImagePrompt({
|
|
291
|
+
prompt: "@kira:1 happy",
|
|
292
|
+
provider: "nano-banana-pro",
|
|
293
|
+
connectedReferences: [kiraFace],
|
|
294
|
+
})
|
|
295
|
+
// No slug-level mode; falls through to node default "face".
|
|
296
|
+
expect(result.prompt).toContain("Take ONLY the facial features")
|
|
297
|
+
// Canonical description omitted because face mode drops it.
|
|
298
|
+
expect(result.prompt).not.toContain("auburn shoulder-length hair")
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it("slug mode overrides character node's defaultUsageMode", () => {
|
|
302
|
+
const kiraFace: ConnectedReference = {
|
|
303
|
+
...kiraCanonical,
|
|
304
|
+
defaultUsageMode: "face",
|
|
305
|
+
}
|
|
306
|
+
const result = buildImagePrompt({
|
|
307
|
+
prompt: "@kira:1:style as a wood carving",
|
|
308
|
+
provider: "nano-banana-pro",
|
|
309
|
+
connectedReferences: [kiraFace],
|
|
310
|
+
})
|
|
311
|
+
expect(result.prompt).toContain("Take only the visual style and tone")
|
|
312
|
+
expect(result.prompt).not.toContain("Take ONLY the facial features")
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it("canonical fallback (no @-mention) uses character node's defaultUsageMode", () => {
|
|
316
|
+
const kiraFace: ConnectedReference = {
|
|
317
|
+
...kiraCanonical,
|
|
318
|
+
defaultUsageMode: "face",
|
|
319
|
+
}
|
|
320
|
+
const result = buildImagePrompt({
|
|
321
|
+
prompt: "a portrait with no mention",
|
|
322
|
+
provider: "nano-banana-pro",
|
|
323
|
+
connectedReferences: [kiraFace],
|
|
324
|
+
})
|
|
325
|
+
// Canonical fallback triggers — directive uses the node's default mode.
|
|
326
|
+
expect(result.prompt).toContain("Take ONLY the facial features")
|
|
327
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-portrait.png")
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it("canonical fallback without defaultUsageMode emits the default 'identical' directive", () => {
|
|
331
|
+
const result = buildImagePrompt({
|
|
332
|
+
prompt: "just a scene",
|
|
333
|
+
provider: "nano-banana-pro",
|
|
334
|
+
connectedReferences: [kiraCanonical],
|
|
335
|
+
})
|
|
336
|
+
// Default mode is "identical" — directive comes from usageModeDirective("identical").
|
|
337
|
+
expect(result.prompt).toContain("The subject must remain exactly the same person")
|
|
338
|
+
expect(result.prompt).toContain("preserve 100% facial identity")
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it("user scenario: shira with smile + laughing variants — both URLs attach, canonical skipped", () => {
|
|
342
|
+
const shiraCanonical: ConnectedReference = {
|
|
343
|
+
id: "char-shira",
|
|
344
|
+
defaultName: "shira",
|
|
345
|
+
source: "wired-character",
|
|
346
|
+
url: "https://r2/shira/laughing.png", // defaultAssetUrl per ★ button
|
|
347
|
+
characterSlug: "shira",
|
|
348
|
+
variantSlug: undefined,
|
|
349
|
+
characterCanonicalDescription: "young woman, brown eyes",
|
|
350
|
+
variantDescription: null,
|
|
351
|
+
variantDisplayName: "canonical",
|
|
352
|
+
}
|
|
353
|
+
const shiraSmile: ConnectedReference = {
|
|
354
|
+
id: "char-shira-smile",
|
|
355
|
+
defaultName: "shira / smile",
|
|
356
|
+
source: "wired-character",
|
|
357
|
+
url: "https://r2/shira/smile.png",
|
|
358
|
+
characterSlug: "shira",
|
|
359
|
+
variantSlug: "smile",
|
|
360
|
+
characterCanonicalDescription: "young woman, brown eyes",
|
|
361
|
+
variantDescription: null,
|
|
362
|
+
variantDisplayName: "smile",
|
|
363
|
+
}
|
|
364
|
+
const shiraLaughing: ConnectedReference = {
|
|
365
|
+
id: "char-shira-laughing",
|
|
366
|
+
defaultName: "shira / laughing",
|
|
367
|
+
source: "wired-character",
|
|
368
|
+
url: "https://r2/shira/laughing.png",
|
|
369
|
+
characterSlug: "shira",
|
|
370
|
+
variantSlug: "laughing",
|
|
371
|
+
characterCanonicalDescription: "young woman, brown eyes",
|
|
372
|
+
variantDescription: null,
|
|
373
|
+
variantDisplayName: "laughing",
|
|
374
|
+
}
|
|
375
|
+
const result = buildImagePrompt({
|
|
376
|
+
prompt:
|
|
377
|
+
"@shira:1:smile with a friend, drinking coffee, @shira:2:laughing laughs when her friend say something to her ear",
|
|
378
|
+
provider: "nano-banana-pro",
|
|
379
|
+
connectedReferences: [shiraCanonical, shiraSmile, shiraLaughing],
|
|
380
|
+
})
|
|
381
|
+
// Both mentioned variant URLs must be in the references list.
|
|
382
|
+
expect(result.referenceImageUrls).toEqual(
|
|
383
|
+
expect.arrayContaining(["https://r2/shira/smile.png", "https://r2/shira/laughing.png"]),
|
|
384
|
+
)
|
|
385
|
+
// Canonical (laughing URL via defaultAssetUrl) should NOT be in refs as a
|
|
386
|
+
// standalone canonical attachment — though note that the laughing variant
|
|
387
|
+
// URL coincidentally matches `defaultAssetUrl` here, that's OK because
|
|
388
|
+
// it's attached as the LAUGHING VARIANT, not as the canonical fallback.
|
|
389
|
+
// The expected behavior is: 2 unique URLs (smile + laughing), no triple-
|
|
390
|
+
// attach of the canonical with the same URL as the laughing variant.
|
|
391
|
+
expect(result.referenceImageUrls).toHaveLength(2)
|
|
392
|
+
// Tokens replaced — no literal `@shira:N:variant` survives.
|
|
393
|
+
expect(result.prompt).not.toMatch(/@shira:1:smile\b/)
|
|
394
|
+
expect(result.prompt).not.toMatch(/@shira:2:laughing\b/)
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
describe("buildImagePrompt with extra reference images (isExtraRef)", () => {
|
|
399
|
+
it("emits 'same subject as Image M' for a character extra whose character is canonically attached", () => {
|
|
400
|
+
const kiraExtra: ConnectedReference = {
|
|
401
|
+
id: "extra-kira-stand",
|
|
402
|
+
defaultName: "Kira / standing",
|
|
403
|
+
source: "wired-character",
|
|
404
|
+
description: "full body, standing, facing right",
|
|
405
|
+
url: "https://r2/kira-standing.png",
|
|
406
|
+
characterSlug: "kira",
|
|
407
|
+
variantSlug: "standing",
|
|
408
|
+
variantDescription: "full body, standing, facing right",
|
|
409
|
+
variantDisplayName: "standing",
|
|
410
|
+
defaultUsageMode: "identical",
|
|
411
|
+
isExtraRef: true,
|
|
412
|
+
}
|
|
413
|
+
const result = buildImagePrompt({
|
|
414
|
+
prompt: "a busy street market",
|
|
415
|
+
provider: "nano-banana-pro",
|
|
416
|
+
connectedReferences: [kiraCanonical, kiraExtra],
|
|
417
|
+
})
|
|
418
|
+
// Kira (canonical) attaches at position 1; extra at position 2.
|
|
419
|
+
expect(result.referenceImageUrls).toEqual([
|
|
420
|
+
"https://r2/kira-portrait.png",
|
|
421
|
+
"https://r2/kira-standing.png",
|
|
422
|
+
])
|
|
423
|
+
// Pair-back directive uses the exact "Image 2 is the same subject as
|
|
424
|
+
// Image 1, …" phrasing called out in the spec.
|
|
425
|
+
expect(result.prompt).toContain(
|
|
426
|
+
"Image 2 is the same subject as Image 1, full body, standing, facing right.",
|
|
427
|
+
)
|
|
428
|
+
// Canonical fallback bullet for Kira now leads with the numeric prefix
|
|
429
|
+
// ("Image 1 (Kira) — …") so the model can correlate ref position with
|
|
430
|
+
// the named character. Without the prefix the bullet was a floating
|
|
431
|
+
// "Kira — …" with no link back to a specific reference slot.
|
|
432
|
+
expect(result.prompt).toContain("Image 1 (Kira) — young woman, brown eyes, auburn shoulder-length hair, athletic build")
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it("emits canonical-style directive for a character extra of a previously-unseen character", () => {
|
|
436
|
+
const danielExtra: ConnectedReference = {
|
|
437
|
+
id: "extra-daniel-1",
|
|
438
|
+
defaultName: "Daniel",
|
|
439
|
+
source: "wired-character",
|
|
440
|
+
description: "looking right, hands in pockets",
|
|
441
|
+
url: "https://r2/daniel-1.png",
|
|
442
|
+
characterSlug: "daniel",
|
|
443
|
+
variantSlug: "look-right",
|
|
444
|
+
variantDescription: "looking right, hands in pockets",
|
|
445
|
+
variantDisplayName: "look-right",
|
|
446
|
+
defaultUsageMode: "identical",
|
|
447
|
+
isExtraRef: true,
|
|
448
|
+
}
|
|
449
|
+
const result = buildImagePrompt({
|
|
450
|
+
prompt: "a busy street market",
|
|
451
|
+
provider: "nano-banana-pro",
|
|
452
|
+
connectedReferences: [danielExtra],
|
|
453
|
+
})
|
|
454
|
+
expect(result.referenceImageUrls).toEqual(["https://r2/daniel-1.png"])
|
|
455
|
+
// First-sight extra: canonical-style directive with the per-ref
|
|
456
|
+
// description as the descriptor.
|
|
457
|
+
expect(result.prompt).toContain("Image 1 (Daniel) — looking right, hands in pockets. The subject must remain exactly the same person")
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it("emits 'Image N (reference): <description>.' for a manual upload extra", () => {
|
|
461
|
+
const manualExtra: ConnectedReference = {
|
|
462
|
+
id: "extra-style",
|
|
463
|
+
defaultName: "Image 1",
|
|
464
|
+
source: "manual",
|
|
465
|
+
description: "warm cinematic color palette",
|
|
466
|
+
url: "https://r2/style-ref.png",
|
|
467
|
+
isExtraRef: true,
|
|
468
|
+
}
|
|
469
|
+
const result = buildImagePrompt({
|
|
470
|
+
prompt: "a portrait",
|
|
471
|
+
provider: "nano-banana-pro",
|
|
472
|
+
connectedReferences: [manualExtra],
|
|
473
|
+
})
|
|
474
|
+
expect(result.referenceImageUrls).toEqual(["https://r2/style-ref.png"])
|
|
475
|
+
expect(result.prompt).toContain(
|
|
476
|
+
"Image 1 (reference): warm cinematic color palette.",
|
|
477
|
+
)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it("matches the spec's example shape — Kira mention + standing extra", () => {
|
|
481
|
+
const kiraExtra: ConnectedReference = {
|
|
482
|
+
id: "extra-kira-stand",
|
|
483
|
+
defaultName: "Kira / standing",
|
|
484
|
+
source: "wired-character",
|
|
485
|
+
description: "full body, standing, facing right",
|
|
486
|
+
url: "https://r2/kira-standing.png",
|
|
487
|
+
characterSlug: "kira",
|
|
488
|
+
variantSlug: "standing",
|
|
489
|
+
variantDescription: "full body, standing, facing right",
|
|
490
|
+
variantDisplayName: "standing",
|
|
491
|
+
defaultUsageMode: "identical",
|
|
492
|
+
isExtraRef: true,
|
|
493
|
+
}
|
|
494
|
+
// Both mention-driven AND canonical-fallback bullets now include the
|
|
495
|
+
// numeric `Image N (Name)` prefix so the model can correlate any bullet
|
|
496
|
+
// to its position in the ref array. The `@kira:1` mention drives the
|
|
497
|
+
// primary bullet at Image 1.
|
|
498
|
+
const result = buildImagePrompt({
|
|
499
|
+
prompt: "@kira:1 outside a coffee shop",
|
|
500
|
+
provider: "nano-banana-pro",
|
|
501
|
+
connectedReferences: [kiraCanonical, kiraExtra],
|
|
502
|
+
})
|
|
503
|
+
// Block opens with "Use these characters:"
|
|
504
|
+
expect(result.prompt.startsWith("Use these characters:\n")).toBe(true)
|
|
505
|
+
// Mention emits the numeric prefix.
|
|
506
|
+
expect(result.prompt).toContain("Image 1 (Kira)")
|
|
507
|
+
expect(result.prompt).toContain(
|
|
508
|
+
"Image 2 is the same subject as Image 1, full body, standing, facing right.",
|
|
509
|
+
)
|
|
510
|
+
// User prompt is preserved at the bottom.
|
|
511
|
+
expect(result.prompt).toContain("outside a coffee shop")
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it("pairs an extra back to a canonical-fallback character with numeric prefix on both", () => {
|
|
515
|
+
// Even without a `@kira:N` mention, the canonical fallback bullet now
|
|
516
|
+
// leads with "Image 1 (Kira) — …" so the model can link the canonical
|
|
517
|
+
// ref to the named character. The extra's "same subject as Image 1"
|
|
518
|
+
// resolves cleanly: position 1 in the ref array IS the canonical URL.
|
|
519
|
+
const kiraExtra: ConnectedReference = {
|
|
520
|
+
id: "extra-kira-stand",
|
|
521
|
+
defaultName: "Kira / standing",
|
|
522
|
+
source: "wired-character",
|
|
523
|
+
description: "full body, standing, facing right",
|
|
524
|
+
url: "https://r2/kira-standing.png",
|
|
525
|
+
characterSlug: "kira",
|
|
526
|
+
variantSlug: "standing",
|
|
527
|
+
variantDescription: "full body, standing, facing right",
|
|
528
|
+
variantDisplayName: "standing",
|
|
529
|
+
defaultUsageMode: "identical",
|
|
530
|
+
isExtraRef: true,
|
|
531
|
+
}
|
|
532
|
+
const result = buildImagePrompt({
|
|
533
|
+
prompt: "outside a coffee shop",
|
|
534
|
+
provider: "nano-banana-pro",
|
|
535
|
+
connectedReferences: [kiraCanonical, kiraExtra],
|
|
536
|
+
})
|
|
537
|
+
// Position 1 = canonical Kira URL; position 2 = extra URL.
|
|
538
|
+
expect(result.referenceImageUrls).toEqual([
|
|
539
|
+
"https://r2/kira-portrait.png",
|
|
540
|
+
"https://r2/kira-standing.png",
|
|
541
|
+
])
|
|
542
|
+
// Canonical fallback bullet now includes the numeric prefix.
|
|
543
|
+
expect(result.prompt).toContain("Image 1 (Kira) — young woman, brown eyes")
|
|
544
|
+
// Pair-back to "Image 1" still emitted.
|
|
545
|
+
expect(result.prompt).toContain(
|
|
546
|
+
"Image 2 is the same subject as Image 1, full body, standing, facing right.",
|
|
547
|
+
)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it("does NOT double-emit when extras coexist with existing positional refs", () => {
|
|
551
|
+
const manualPositional: ConnectedReference = {
|
|
552
|
+
id: "pos-1",
|
|
553
|
+
defaultName: "Image 1",
|
|
554
|
+
source: "manual",
|
|
555
|
+
url: "https://r2/positional.png",
|
|
556
|
+
// No description / no isExtraRef — legacy positional ref.
|
|
557
|
+
}
|
|
558
|
+
const manualExtra: ConnectedReference = {
|
|
559
|
+
id: "extra-1",
|
|
560
|
+
defaultName: "Image 2",
|
|
561
|
+
source: "manual",
|
|
562
|
+
description: "warm light",
|
|
563
|
+
url: "https://r2/extra.png",
|
|
564
|
+
isExtraRef: true,
|
|
565
|
+
}
|
|
566
|
+
const result = buildImagePrompt({
|
|
567
|
+
prompt: "a portrait",
|
|
568
|
+
provider: "nano-banana-pro",
|
|
569
|
+
connectedReferences: [manualPositional, manualExtra],
|
|
570
|
+
})
|
|
571
|
+
// Only the extra produces its dedicated reference bullet; the
|
|
572
|
+
// positional ref doesn't (it has no description and no isExtraRef).
|
|
573
|
+
const refBullets = (result.prompt.match(/Image \d+ \(reference\)/g) || []).length
|
|
574
|
+
expect(refBullets).toBe(1)
|
|
575
|
+
// Both URLs are in the final list, no duplicates.
|
|
576
|
+
expect(result.referenceImageUrls).toEqual(
|
|
577
|
+
expect.arrayContaining(["https://r2/positional.png", "https://r2/extra.png"]),
|
|
578
|
+
)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it("respects per-ref usageMode override on a character extra", () => {
|
|
582
|
+
const kiraExtraStyleMode: ConnectedReference = {
|
|
583
|
+
id: "extra-kira-style",
|
|
584
|
+
defaultName: "Kira / look",
|
|
585
|
+
source: "wired-character",
|
|
586
|
+
description: "1970s film grain look",
|
|
587
|
+
url: "https://r2/kira-look.png",
|
|
588
|
+
characterSlug: "kira",
|
|
589
|
+
variantSlug: "look",
|
|
590
|
+
variantDescription: "1970s film grain look",
|
|
591
|
+
variantDisplayName: "look",
|
|
592
|
+
// Per-ref override resolved at construction time.
|
|
593
|
+
defaultUsageMode: "style",
|
|
594
|
+
isExtraRef: true,
|
|
595
|
+
}
|
|
596
|
+
const result = buildImagePrompt({
|
|
597
|
+
prompt: "a portrait",
|
|
598
|
+
provider: "nano-banana-pro",
|
|
599
|
+
connectedReferences: [kiraExtraStyleMode],
|
|
600
|
+
})
|
|
601
|
+
// "style" mode directive should be emitted (and identity-lock language
|
|
602
|
+
// suppressed) because this is a first-sight character extra.
|
|
603
|
+
expect(result.prompt).toContain("Take only the visual style and tone")
|
|
604
|
+
expect(result.prompt).not.toContain("The subject must remain exactly the same person")
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
it("leaves empty-extras workflows unchanged (no Use these characters: prefix)", () => {
|
|
608
|
+
const result = buildImagePrompt({
|
|
609
|
+
prompt: "a portrait",
|
|
610
|
+
provider: "nano-banana-pro",
|
|
611
|
+
connectedReferences: [],
|
|
612
|
+
})
|
|
613
|
+
expect(result.prompt).not.toMatch(/^Use these characters:/)
|
|
614
|
+
})
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
// "name" and "none" minimal-intervention usage modes
|
|
619
|
+
//
|
|
620
|
+
// These two modes deliberately suppress part (or all) of the per-mention
|
|
621
|
+
// textual prompt so the model isn't biased beyond the attached image:
|
|
622
|
+
// - "name": keeps the labeled subject ("Image N (Name)") but DROPS the
|
|
623
|
+
// directive sentence — the model is told who the character is so it can
|
|
624
|
+
// correlate the position with a named entity, without being told how to
|
|
625
|
+
// consume the image.
|
|
626
|
+
// - "none": suppresses the bullet entirely; the user's `@<slug>:N(:variant):none`
|
|
627
|
+
// token is replaced inline with `Image N` (bare positional reference) so
|
|
628
|
+
// the sentence still parses, but the model receives no character name
|
|
629
|
+
// and no directive — just the attached image at position N. If EVERY
|
|
630
|
+
// mention of a character is "none", that character contributes zero
|
|
631
|
+
// bullets, so the "Use these characters:" header omits it entirely.
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
describe("buildImagePrompt with 'name' / 'none' usage modes", () => {
|
|
635
|
+
it("@kira:1:name keeps the labeled subject but emits NO directive sentence", () => {
|
|
636
|
+
const result = buildImagePrompt({
|
|
637
|
+
prompt: "@kira:1:name at a coffee shop",
|
|
638
|
+
provider: "nano-banana-pro",
|
|
639
|
+
connectedReferences: [kiraCanonical],
|
|
640
|
+
})
|
|
641
|
+
// URL still attaches.
|
|
642
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-portrait.png")
|
|
643
|
+
// Header is present (this character has a non-none mention).
|
|
644
|
+
expect(result.prompt).toContain("Use these characters:")
|
|
645
|
+
// Bullet has the name but no trailing directive language.
|
|
646
|
+
expect(result.prompt).toContain("- Image 1 (Kira)")
|
|
647
|
+
expect(result.prompt).not.toContain("The subject must remain exactly the same person")
|
|
648
|
+
expect(result.prompt).not.toContain("Take ONLY the facial features")
|
|
649
|
+
expect(result.prompt).not.toContain("auburn shoulder-length hair")
|
|
650
|
+
// Inline replacement keeps the character name so the sentence reads naturally.
|
|
651
|
+
expect(result.prompt).toContain("Kira at a coffee shop")
|
|
652
|
+
expect(result.prompt).not.toMatch(/@kira:1:name\b/)
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
it("@kira:1:none emits NO bullet, NO header, and substitutes the positional reference inline", () => {
|
|
656
|
+
const result = buildImagePrompt({
|
|
657
|
+
prompt: "show @kira:1:none dancing",
|
|
658
|
+
provider: "nano-banana-pro",
|
|
659
|
+
connectedReferences: [kiraCanonical],
|
|
660
|
+
})
|
|
661
|
+
// URL still attaches — the image is the whole point.
|
|
662
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-portrait.png")
|
|
663
|
+
// No header at all: the only character has no bullets.
|
|
664
|
+
expect(result.prompt).not.toContain("Use these characters:")
|
|
665
|
+
// No directive sentence anywhere.
|
|
666
|
+
expect(result.prompt).not.toContain("The subject must remain exactly the same person")
|
|
667
|
+
expect(result.prompt).not.toContain("Take ONLY the facial features")
|
|
668
|
+
// Name MUST NOT appear in parens (no `(Kira)` label).
|
|
669
|
+
expect(result.prompt).not.toContain("(Kira)")
|
|
670
|
+
// Inline replacement is the bare positional reference, NOT the name.
|
|
671
|
+
expect(result.prompt).toContain("show Image 1 dancing")
|
|
672
|
+
expect(result.prompt).not.toContain("show Kira dancing")
|
|
673
|
+
expect(result.prompt).not.toMatch(/@kira:1:none\b/)
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it("mixed: same character used with 'none' AND 'face' — header present, 'none' bare, 'face' full directive", () => {
|
|
677
|
+
// The user types two mentions of the same character. The first asks for
|
|
678
|
+
// the visual alone (mode: none → no bullet), the second asks for the
|
|
679
|
+
// face only (mode: face → labeled + face directive). The "Use these
|
|
680
|
+
// characters:" header MUST be present (at least one non-none mention),
|
|
681
|
+
// and the two mentions emit different textual treatments.
|
|
682
|
+
const result = buildImagePrompt({
|
|
683
|
+
prompt: "show @shira:1:none with @shira:2:face mode",
|
|
684
|
+
provider: "nano-banana-pro",
|
|
685
|
+
connectedReferences: [{
|
|
686
|
+
...kiraCanonical,
|
|
687
|
+
id: "char-shira",
|
|
688
|
+
defaultName: "shira",
|
|
689
|
+
characterSlug: "shira",
|
|
690
|
+
}],
|
|
691
|
+
})
|
|
692
|
+
// Header included because at least one non-none mention exists.
|
|
693
|
+
expect(result.prompt).toContain("Use these characters:")
|
|
694
|
+
// The 'face' mention emits the labeled bullet with the face-only directive.
|
|
695
|
+
expect(result.prompt).toContain("- Image 2 (shira)")
|
|
696
|
+
expect(result.prompt).toContain("Take ONLY the facial features")
|
|
697
|
+
// The 'none' mention emits NO bullet for slot 1.
|
|
698
|
+
expect(result.prompt).not.toMatch(/- Image 1 \(/)
|
|
699
|
+
// Inline replacements: slot 1 reads as "Image 1" (bare), slot 2 reads as "shira".
|
|
700
|
+
expect(result.prompt).toContain("show Image 1 with shira mode")
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
it("two characters, one 'none' the other 'identical' — header includes ONLY the identical one", () => {
|
|
704
|
+
// Adam (identical) is mentioned and gets a full bullet. Kira (none) gets
|
|
705
|
+
// her URL attached but no bullet.
|
|
706
|
+
const result = buildImagePrompt({
|
|
707
|
+
prompt: "@kira:1:none in the background, @adam:2 in front",
|
|
708
|
+
provider: "nano-banana-pro",
|
|
709
|
+
connectedReferences: [kiraCanonical, adamCanonical],
|
|
710
|
+
})
|
|
711
|
+
// Both URLs attach.
|
|
712
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-portrait.png")
|
|
713
|
+
expect(result.referenceImageUrls).toContain("https://r2/adam-portrait.png")
|
|
714
|
+
// Header is present because Adam emits a bullet.
|
|
715
|
+
expect(result.prompt).toContain("Use these characters:")
|
|
716
|
+
// Adam's bullet present with identity-lock language.
|
|
717
|
+
expect(result.prompt).toContain("Image 2 (Adam)")
|
|
718
|
+
expect(result.prompt).toContain("The subject must remain exactly the same person")
|
|
719
|
+
// Kira's bullet ABSENT — no `Image 1 (Kira)` text anywhere.
|
|
720
|
+
expect(result.prompt).not.toContain("(Kira)")
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
it("canonical fallback with character.defaultUsageMode = 'name' emits labeled bullet, no directive", () => {
|
|
724
|
+
const kiraNameMode: ConnectedReference = {
|
|
725
|
+
...kiraCanonical,
|
|
726
|
+
defaultUsageMode: "name",
|
|
727
|
+
}
|
|
728
|
+
const result = buildImagePrompt({
|
|
729
|
+
prompt: "a portrait scene",
|
|
730
|
+
provider: "nano-banana-pro",
|
|
731
|
+
connectedReferences: [kiraNameMode],
|
|
732
|
+
})
|
|
733
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-portrait.png")
|
|
734
|
+
expect(result.prompt).toContain("Use these characters:")
|
|
735
|
+
expect(result.prompt).toContain("- Image 1 (Kira)")
|
|
736
|
+
expect(result.prompt).not.toContain("The subject must remain exactly the same person")
|
|
737
|
+
expect(result.prompt).not.toContain("auburn shoulder-length hair")
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
it("canonical fallback with character.defaultUsageMode = 'none' attaches URL but emits no bullet/header", () => {
|
|
741
|
+
const kiraNoneMode: ConnectedReference = {
|
|
742
|
+
...kiraCanonical,
|
|
743
|
+
defaultUsageMode: "none",
|
|
744
|
+
}
|
|
745
|
+
const result = buildImagePrompt({
|
|
746
|
+
prompt: "a portrait scene",
|
|
747
|
+
provider: "nano-banana-pro",
|
|
748
|
+
connectedReferences: [kiraNoneMode],
|
|
749
|
+
})
|
|
750
|
+
// URL still attached.
|
|
751
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-portrait.png")
|
|
752
|
+
// No header at all (only character, no bullets).
|
|
753
|
+
expect(result.prompt).not.toContain("Use these characters:")
|
|
754
|
+
expect(result.prompt).not.toContain("(Kira)")
|
|
755
|
+
expect(result.prompt).not.toContain("The subject must remain exactly the same person")
|
|
756
|
+
// User prompt is preserved verbatim (no transformation since there's no
|
|
757
|
+
// mention token to replace).
|
|
758
|
+
expect(result.prompt).toContain("a portrait scene")
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
it("mixed canonical fallback: one 'none' character + one 'identical' character — header lists only identical", () => {
|
|
762
|
+
const kiraNone: ConnectedReference = { ...kiraCanonical, defaultUsageMode: "none" }
|
|
763
|
+
const result = buildImagePrompt({
|
|
764
|
+
prompt: "a scene",
|
|
765
|
+
provider: "nano-banana-pro",
|
|
766
|
+
connectedReferences: [kiraNone, adamCanonical],
|
|
767
|
+
})
|
|
768
|
+
// Both URLs attached.
|
|
769
|
+
expect(result.referenceImageUrls).toContain("https://r2/kira-portrait.png")
|
|
770
|
+
expect(result.referenceImageUrls).toContain("https://r2/adam-portrait.png")
|
|
771
|
+
// Header present because Adam emits a bullet.
|
|
772
|
+
expect(result.prompt).toContain("Use these characters:")
|
|
773
|
+
// Adam's bullet present.
|
|
774
|
+
expect(result.prompt).toContain("Adam")
|
|
775
|
+
// Kira's name not in any bullet.
|
|
776
|
+
expect(result.prompt).not.toContain("(Kira)")
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
it("character extra (isExtraRef) with mode 'none' attaches URL but emits no bullet/pair-back", () => {
|
|
780
|
+
const kiraExtraNone: ConnectedReference = {
|
|
781
|
+
id: "extra-kira-stand",
|
|
782
|
+
defaultName: "Kira / standing",
|
|
783
|
+
source: "wired-character",
|
|
784
|
+
description: "full body, standing, facing right",
|
|
785
|
+
url: "https://r2/kira-standing.png",
|
|
786
|
+
characterSlug: "kira",
|
|
787
|
+
variantSlug: "standing",
|
|
788
|
+
variantDescription: "full body, standing, facing right",
|
|
789
|
+
variantDisplayName: "standing",
|
|
790
|
+
defaultUsageMode: "none",
|
|
791
|
+
isExtraRef: true,
|
|
792
|
+
}
|
|
793
|
+
const result = buildImagePrompt({
|
|
794
|
+
prompt: "a busy market",
|
|
795
|
+
provider: "nano-banana-pro",
|
|
796
|
+
connectedReferences: [kiraCanonical, kiraExtraNone],
|
|
797
|
+
})
|
|
798
|
+
// Both URLs land.
|
|
799
|
+
expect(result.referenceImageUrls).toEqual([
|
|
800
|
+
"https://r2/kira-portrait.png",
|
|
801
|
+
"https://r2/kira-standing.png",
|
|
802
|
+
])
|
|
803
|
+
// Canonical bullet for Kira still present (mode defaulting to identical).
|
|
804
|
+
expect(result.prompt).toContain("Image 1 (Kira)")
|
|
805
|
+
// No pair-back bullet for the 'none' extra.
|
|
806
|
+
expect(result.prompt).not.toContain("Image 2 is the same subject as Image 1")
|
|
807
|
+
expect(result.prompt).not.toContain("Image 2 (reference)")
|
|
808
|
+
})
|
|
809
|
+
})
|