@nodaro/prompts 1.34.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -185,10 +185,10 @@ describe("generate-image factory preset data validity", () => {
185
185
  const char = presets.find((p) => p.id === "generate-image/character-board")
186
186
  const loc = presets.find((p) => p.id === "generate-image/location-board")
187
187
  expect(char?.group).toBe("Reference Sheet")
188
- expect(char?.data.provider).toBe("nano-banana-pro")
188
+ expect(char?.data.provider).toBe("gpt-image-2")
189
189
  expect(char?.data.resolution).toBe("2K")
190
190
  expect(loc?.group).toBe("Reference Sheet")
191
- expect(loc?.data.provider).toBe("nano-banana-pro")
191
+ expect(loc?.data.provider).toBe("gpt-image-2")
192
192
  expect(loc?.data.resolution).toBe("2K")
193
193
  })
194
194
 
@@ -200,7 +200,7 @@ describe("generate-image factory preset data validity", () => {
200
200
  const b = presets.find((p) => p.id === id)
201
201
  expect(b, `${id} missing`).toBeTruthy()
202
202
  expect(b!.group).toBe("Reference Sheet")
203
- expect(b!.data.provider).toBe("nano-banana-pro")
203
+ expect(b!.data.provider).toBe("gpt-image-2")
204
204
  expect(b!.data.resolution).toBe("2K")
205
205
  expect((b!.data.negativePrompt as string)?.length).toBeGreaterThan(0)
206
206
  }
@@ -215,7 +215,7 @@ describe("generate-image factory preset data validity", () => {
215
215
  const b = presets.find((p) => p.id === id)
216
216
  expect(b, `${id} missing`).toBeTruthy()
217
217
  expect(b!.group).toBe("Reference Sheet")
218
- expect(b!.data.provider).toBe("nano-banana-pro")
218
+ expect(b!.data.provider).toBe("gpt-image-2")
219
219
  expect(b!.data.resolution).toBe("2K")
220
220
  expect((b!.data.negativePrompt as string)?.length).toBeGreaterThan(0)
221
221
  }
@@ -234,20 +234,27 @@ describe("generate-image factory preset data validity", () => {
234
234
  }
235
235
  })
236
236
 
237
- it("includes the Cast & Consistency grids on nano-banana-2-1", () => {
238
- // Grids are FED BACK as identity references — they ride Nano Banana 2.1
239
- // (cheap, consistency-strong) at 4K so reused panel faces stay sharp.
237
+ it("no factory preset defaults to Nano Banana Pro (2026-10-08 re-test)", () => {
238
+ for (const nodeType of ["generate-image", "modify-image"] as const) {
239
+ for (const p of getFactoryPresets(nodeType)) expect(p.data.provider, p.id).not.toBe("nano-banana-pro")
240
+ }
241
+ })
242
+
243
+ it("includes the Cast & Consistency grids on gpt-image-2", () => {
244
+ // Grids are FED BACK as identity references — they ride GPT Image 2, the
245
+ // platform's character model (2026-10-08 bake-off), at 4K so reused panel
246
+ // faces stay sharp.
240
247
  for (const id of ["generate-image/character-reference-grid", "generate-image/cast-mega-grid"]) {
241
248
  const g = presets.find((p) => p.id === id)
242
249
  expect(g, `${id} missing`).toBeTruthy()
243
250
  expect(g!.group).toBe("Cast & Consistency")
244
- expect(g!.data.provider).toBe("nano-banana-2-1")
251
+ expect(g!.data.provider).toBe("gpt-image-2")
245
252
  expect(g!.data.aspectRatio).toBe("3:4")
246
253
  expect(g!.data.resolution).toBe("4K")
247
254
  }
248
255
  const scene = presets.find((p) => p.id === "generate-image/cast-scene")
249
256
  expect(scene?.group).toBe("Cast & Consistency")
250
- expect(scene?.data.provider).toBe("nano-banana-2-1")
257
+ expect(scene?.data.provider).toBe("gpt-image-2")
251
258
  })
252
259
 
253
260
  it("ships the Handmade & Stop-Motion family with the in-prompt NOT-digital-CG clause", () => {
@@ -260,13 +267,15 @@ describe("generate-image factory preset data validity", () => {
260
267
  }
261
268
  })
262
269
 
263
- it("shares the Doodle Overlay edits with modify-image (same catalog, provider valid for both)", () => {
270
+ it("shares the Doodle Overlay edits with modify-image (same catalog, the edit model on each node)", () => {
264
271
  for (const nodeType of ["generate-image", "modify-image"] as const) {
265
272
  for (const slug of ["doodle-overlay", "doodle-overlay-expressive"]) {
266
273
  const p = getFactoryPresets(nodeType).find((x) => x.id === `${nodeType}/${slug}`)
267
274
  expect(p, `${nodeType}/${slug} missing`).toBeTruthy()
268
275
  expect(p!.group).toBe("Edits")
269
- expect(p!.data.provider).toBe("nano-banana-pro")
276
+ // The edit model, as the id each node accepts (Generate Image swaps the
277
+ // t2i id to its i2i sibling once a reference is connected).
278
+ expect(p!.data.provider).toBe(nodeType === "modify-image" ? "gpt-image-2-5-flare-i2i" : "gpt-image-2-5-flare")
270
279
  }
271
280
  }
272
281
  })
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import type { ConnectedReference } from "@nodaro/shared"
3
+ import { buildImagePrompt, type BuildImagePromptConfig } from "../prompt-builder.js"
4
+
5
+ /**
6
+ * WIRED-case golden: every `{image:N}` token below binds to a reference (or
7
+ * the prompt carries no token at all), so the unwired-token rule must leave
8
+ * each output BYTE-IDENTICAL. The snapshot was captured from the assembly as
9
+ * it was before that rule existed; a change to any entry here means a wired
10
+ * output moved.
11
+ *
12
+ * Covers both engines' shapes: the flat path (`referenceImageUrls` only, the
13
+ * orchestrator's images-only branch) and the connected-reference path (the
14
+ * canvas, the route's structured mode), in the legacy and hybrid formats,
15
+ * with and without a wired character, style, negative, reorder, and a
16
+ * provider that takes no reference images.
17
+ */
18
+
19
+ const hat: ConnectedReference = { id: "up-hat", defaultName: "Hat", source: "wired-image", url: "https://r2/hat.png" }
20
+ const coat: ConnectedReference = { id: "up-coat", defaultName: "Coat", source: "wired-image", url: "https://r2/coat.png" }
21
+ const face: ConnectedReference = { id: "face-1", defaultName: "Sarah", source: "wired-face", url: "https://r2/sarah.png", description: "tall, red hair" }
22
+ const kira: ConnectedReference = {
23
+ id: "char-kira",
24
+ defaultName: "Kira",
25
+ source: "wired-character",
26
+ url: "https://r2/kira.png",
27
+ characterSlug: "kira",
28
+ variantDisplayName: "canonical",
29
+ characterCanonicalDescription: "auburn shoulder-length hair",
30
+ }
31
+
32
+ const PROMPTS: Record<string, string> = {
33
+ creatorAtHome: "Waist-up photo of {image:1:person}, an adult, standing at home in front of a plain wall, arms relaxed, empty hands.",
34
+ productInHand: "An adult woman in her thirties holds {image:1:product} at chest height toward the viewer, label facing out, in a bright room at home.",
35
+ skinDetailSheet: "Create a single skin detail reference sheet titled \"SKIN DETAIL SHEET\" from {image:1:person}, the single source of truth for identity, skin tone and features. Six panels: PANEL 01 — FULL FACE (front, eye level) · PANEL 02 — LEFT CHEEK & NOSE. In every panel: Skin shows its real structure. Plain neutral grey background, thin white panel labels in English.",
36
+ facePrivacy: "remove persons faces from {image:1}, keep everything else the same",
37
+ twoTokens: "{image:1:person} with {image:2:face}",
38
+ pillSpacing: "a man wearing {image:1:hat} in the park",
39
+ multiLine: "Scene:\n {image:1:person} stands still\n\n{image:2} behind",
40
+ tokenFreeSpacing: "a cat on a mat ",
41
+ tokenFreeIndented: "Shot list:\n 1. wide\n 2. close up",
42
+ }
43
+
44
+ type Shape = (prompt: string) => BuildImagePromptConfig
45
+
46
+ const SHAPES: Record<string, Shape> = {
47
+ "flat path, two refs": (prompt) => ({
48
+ prompt, provider: "nano-banana-pro", referenceImageUrls: [hat.url!, coat.url!],
49
+ }),
50
+ "flat path, two ancestor refs, style + negative": (prompt) => ({
51
+ prompt, provider: "nano-banana-pro", ancestorRefs: [hat.url!, coat.url!], style: "cinematic", negativePrompt: "blurry",
52
+ }),
53
+ "flat path, provider without reference support": (prompt) => ({
54
+ prompt, provider: "ideogram-v3", referenceImageUrls: [hat.url!, coat.url!],
55
+ }),
56
+ "connected refs, legacy": (prompt) => ({
57
+ prompt, provider: "nano-banana-pro", connectedReferences: [hat, coat],
58
+ }),
59
+ "connected refs, legacy, face + image, style + negative": (prompt) => ({
60
+ prompt, provider: "nano-banana-pro", connectedReferences: [face, coat], style: "cinematic", negativePrompt: "blurry",
61
+ }),
62
+ "connected refs, legacy, reorder": (prompt) => ({
63
+ prompt, provider: "nano-banana-pro", connectedReferences: [hat, coat], referenceOrder: ["wired:up-coat", "wired:up-hat"],
64
+ }),
65
+ "connected refs, legacy, wired character first": (prompt) => ({
66
+ prompt, provider: "nano-banana-pro", connectedReferences: [kira, hat, coat],
67
+ }),
68
+ "connected refs, hybrid": (prompt) => ({
69
+ prompt, provider: "nano-banana-pro", connectedReferences: [hat, coat], referenceFormat: "hybrid",
70
+ }),
71
+ "connected refs, hybrid, wired character first": (prompt) => ({
72
+ prompt, provider: "nano-banana-pro", connectedReferences: [kira, hat, coat], referenceFormat: "hybrid",
73
+ }),
74
+ "connected refs, hybrid, bound token in the negative": (prompt) => ({
75
+ prompt, provider: "nano-banana-pro", connectedReferences: [hat, coat], referenceFormat: "hybrid", negativePrompt: "{image:1:hat} on the floor",
76
+ }),
77
+ "connected refs, legacy, provider without reference support": (prompt) => ({
78
+ prompt, provider: "ideogram-v3", connectedReferences: [hat, coat],
79
+ }),
80
+ }
81
+
82
+ describe("image prompt assembly — wired reference tokens are byte-identical", () => {
83
+ for (const [shapeName, shape] of Object.entries(SHAPES)) {
84
+ for (const [promptName, prompt] of Object.entries(PROMPTS)) {
85
+ it(`${shapeName} · ${promptName}`, () => {
86
+ expect(buildImagePrompt(shape(prompt))).toMatchSnapshot()
87
+ })
88
+ }
89
+ }
90
+ })
@@ -0,0 +1,283 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { readPromptAffixes, type ConnectedReference } from "@nodaro/shared"
3
+ import { buildImagePrompt, buildImagePromptSegments, type BuildImagePromptConfig } from "../prompt-builder.js"
4
+ import { assembleImageInput } from "../assemble-image-input.js"
5
+ import { resolveReferenceTokens } from "../video-reference-resolver.js"
6
+ import { applyPromptAffixes } from "../resolve-prompt.js"
7
+ import { GENERATE_IMAGE_PRESETS } from "../factory-presets/generate-image.js"
8
+
9
+ /**
10
+ * An `{image:N}` token with no image at its position follows the VIDEO rule
11
+ * (`resolveReferenceTokens`): a labelled token becomes its label, an unlabelled
12
+ * one is dropped, and the horizontal gap it leaves is collapsed. Before this,
13
+ * the image path shipped the raw token to the model — a preset written for a
14
+ * wired photo (`Waist-up photo of {image:1:person}, …`) run without one sent
15
+ * the literal `{image:1:person}`.
16
+ *
17
+ * A token that binds renders exactly as before (the wired golden pins that);
18
+ * the tidy only runs when a token was actually dropped.
19
+ */
20
+
21
+ const ZERO = { image: 0, video: 0, audio: 0 }
22
+ const PROVIDER = "nano-banana-pro"
23
+
24
+ const hat: ConnectedReference = { id: "up-hat", defaultName: "Hat", source: "wired-image", url: "https://r2/hat.png" }
25
+ const photo: ConnectedReference = { id: "up-photo", defaultName: "Photo", source: "wired-image", url: "https://r2/photo.png" }
26
+ const kira: ConnectedReference = {
27
+ id: "char-kira",
28
+ defaultName: "Kira",
29
+ source: "wired-character",
30
+ url: "https://r2/kira.png",
31
+ characterSlug: "kira",
32
+ variantDisplayName: "canonical",
33
+ characterCanonicalDescription: "auburn shoulder-length hair",
34
+ }
35
+
36
+ // The prompt shapes of the realism presets that ask for a wired photo (Creator
37
+ // at Home, Product in Hand, Skin Detail Sheet), as plain strings.
38
+ const CREATOR_AT_HOME =
39
+ "Waist-up photo of {image:1:person}, an adult, standing at home in front of a plain wall, arms relaxed, empty hands."
40
+ const PRODUCT_IN_HAND =
41
+ "An adult woman in her thirties holds {image:1:product} at chest height toward the viewer, label facing out, in a bright room at home."
42
+ const SKIN_DETAIL_SHEET =
43
+ "Create a single skin detail reference sheet titled \"SKIN DETAIL SHEET\" from {image:1:person}, the single source of truth for identity, skin tone and features. Six panels: PANEL 01 — FULL FACE (front, eye level) · PANEL 02 — LEFT CHEEK & NOSE. In every panel: Skin shows its real structure. Plain neutral grey background, thin white panel labels in English."
44
+ // The Face Privacy presets on dev: an unlabelled `{image:1}`.
45
+ const FACE_PRIVACY = "remove persons faces from {image:1}, keep everything else the same"
46
+
47
+ const CREATOR_UNWIRED =
48
+ "Waist-up photo of person, an adult, standing at home in front of a plain wall, arms relaxed, empty hands."
49
+
50
+ /** The three ways a prompt reaches `buildImagePrompt` with nothing wired. */
51
+ const UNWIRED_SHAPES: Record<string, (prompt: string) => BuildImagePromptConfig> = {
52
+ "flat path": (prompt) => ({ prompt, provider: PROVIDER }),
53
+ "connected refs, legacy": (prompt) => ({ prompt, provider: PROVIDER, connectedReferences: [] }),
54
+ "connected refs, hybrid": (prompt) => ({ prompt, provider: PROVIDER, connectedReferences: [], referenceFormat: "hybrid" }),
55
+ }
56
+
57
+ describe("unwired {image:N} — parity with the video resolver", () => {
58
+ // Prompts whose first letter is already upper-case, or that open on a
59
+ // token, so the hybrid scene's line-initial capitalizer changes nothing and
60
+ // the three image shapes and the video resolver must agree byte-for-byte.
61
+ const PARITY_PROMPTS = [
62
+ CREATOR_AT_HOME,
63
+ PRODUCT_IN_HAND,
64
+ SKIN_DETAIL_SHEET,
65
+ "{image:1:person} with {image:2:face}",
66
+ "Walk past {image:9} slowly, then {image:4:dog} sits.",
67
+ ]
68
+ for (const [shapeName, shape] of Object.entries(UNWIRED_SHAPES)) {
69
+ for (const prompt of PARITY_PROMPTS) {
70
+ it(`${shapeName}: ${prompt.slice(0, 48)}…`, () => {
71
+ const image = buildImagePrompt(shape(prompt)).prompt
72
+ expect(image).toBe(resolveReferenceTokens(prompt, ZERO))
73
+ expect(image).not.toMatch(/\{image:/i)
74
+ })
75
+ }
76
+ }
77
+ })
78
+
79
+ describe("unwired {image:N} — flat path (references by URL only)", () => {
80
+ const flat = (prompt: string, urls: string[] = [], extra: Partial<BuildImagePromptConfig> = {}) =>
81
+ buildImagePrompt({ prompt, provider: PROVIDER, referenceImageUrls: urls, ...extra }).prompt
82
+
83
+ it("a labelled token with nothing wired becomes its label", () => {
84
+ expect(flat(CREATOR_AT_HOME)).toBe(CREATOR_UNWIRED)
85
+ })
86
+
87
+ it("an unlabelled token with nothing wired is dropped (punctuation is left as the video rule leaves it)", () => {
88
+ expect(flat(FACE_PRIVACY)).toBe("remove persons faces from , keep everything else the same")
89
+ })
90
+
91
+ it("only the token past the last wired image drops; the wired one expands as before", () => {
92
+ expect(flat("{image:1} and {image:3:dog} play", [hat.url!, photo.url!])).toBe("Image 1 and dog play")
93
+ })
94
+
95
+ it("{image:0} is never wired", () => {
96
+ expect(flat("a {image:0:ghost} here", [hat.url!])).toBe("a ghost here")
97
+ })
98
+
99
+ it("collapses the gap a dropped token leaves, and trims", () => {
100
+ expect(flat("walk past {image:9} slowly", [hat.url!])).toBe("walk past slowly")
101
+ expect(flat("{image:2} a cat on a mat", [hat.url!])).toBe("a cat on a mat")
102
+ })
103
+
104
+ it("the collapse is horizontal only — the Avoid line keeps its own line", () => {
105
+ expect(flat("a {image:2:dog} runs", [hat.url!], { negativePrompt: "blurry" })).toBe("a dog runs\nAvoid: blurry")
106
+ })
107
+
108
+ it("nothing is tidied when every token binds", () => {
109
+ expect(flat("a cat {image:1}", [hat.url!])).toBe("a cat Image 1")
110
+ })
111
+
112
+ it("the label is taken as written, whatever its characters", () => {
113
+ expect(flat("a {image:1:man's jacket} on a hook")).toBe("a man's jacket on a hook")
114
+ expect(flat("{IMAGE:1:Person} waves")).toBe("Person waves")
115
+ })
116
+ })
117
+
118
+ describe("unwired {image:N} — connected references, legacy format", () => {
119
+ it("the canvas shape with nothing wired (an empty reference list) drops to the label", () => {
120
+ expect(buildImagePrompt({ prompt: CREATOR_AT_HOME, provider: PROVIDER, connectedReferences: [] }).prompt)
121
+ .toBe(CREATOR_UNWIRED)
122
+ })
123
+
124
+ it("a wired token keeps its directive and expansion; the unwired one becomes its label", () => {
125
+ const result = buildImagePrompt({
126
+ prompt: "{image:1:hat} on {image:2:dog}",
127
+ provider: PROVIDER,
128
+ connectedReferences: [hat],
129
+ })
130
+ expect(result.prompt).toBe(
131
+ "Use these references for the output image:\n- Image 1 (hat) — match exactly.\n\nCompose them naturally into a single image: Image 1 (hat) on dog",
132
+ )
133
+ expect(result.referenceImageUrls).toEqual([hat.url])
134
+ })
135
+ })
136
+
137
+ describe("unwired {image:N} — connected references, hybrid format", () => {
138
+ it("drops to the label with nothing wired (the label keeps its case, as on video)", () => {
139
+ expect(buildImagePrompt({
140
+ prompt: "{image:1:person} with {image:2:face}",
141
+ provider: PROVIDER,
142
+ connectedReferences: [],
143
+ referenceFormat: "hybrid",
144
+ }).prompt).toBe("person with face")
145
+ })
146
+
147
+ it("a wired token keeps its lettered phrase; the unwired one becomes its label and its seam collapses", () => {
148
+ expect(buildImagePrompt({
149
+ prompt: "a man wearing {image:1:hat} and {image:7:scarf} in the park",
150
+ provider: PROVIDER,
151
+ connectedReferences: [hat],
152
+ referenceFormat: "hybrid",
153
+ }).prompt).toBe("A man wearing the hat from reference image A and scarf in the park")
154
+ })
155
+
156
+ it("an unlabelled Face Privacy token with nothing wired is dropped", () => {
157
+ expect(buildImagePrompt({
158
+ prompt: FACE_PRIVACY,
159
+ provider: PROVIDER,
160
+ connectedReferences: [],
161
+ referenceFormat: "hybrid",
162
+ }).prompt).toBe("Remove persons faces from , keep everything else the same")
163
+ })
164
+ })
165
+
166
+ // `{image:N}` numbers the NON-character references: a wired Character is
167
+ // bound through its `@`-mention or its canonical directive, never through a
168
+ // positional token. So a token with only a Character wired has no image at its
169
+ // position and drops to its label, while the Character still attaches.
170
+ describe("unwired {image:N} — a wired Character is mention-only", () => {
171
+ for (const referenceFormat of ["legacy", "hybrid"] as const) {
172
+ it(`${referenceFormat}: Character unwired → the token drops to its label`, () => {
173
+ expect(buildImagePrompt({
174
+ prompt: CREATOR_AT_HOME,
175
+ provider: PROVIDER,
176
+ connectedReferences: [],
177
+ referenceFormat,
178
+ }).prompt).toBe(CREATOR_UNWIRED)
179
+ })
180
+ }
181
+
182
+ it("legacy: Character wired → the token drops to its label, the canonical directive still attaches it", () => {
183
+ const result = buildImagePrompt({ prompt: CREATOR_AT_HOME, provider: PROVIDER, connectedReferences: [kira] })
184
+ expect(result.prompt.startsWith("Use these characters:\n- Image 1 (Kira) — auburn shoulder-length hair.")).toBe(true)
185
+ expect(result.prompt.endsWith(`\n\n${CREATOR_UNWIRED}`)).toBe(true)
186
+ expect(result.prompt).not.toContain("{image:")
187
+ expect(result.referenceImageUrls).toEqual([kira.url])
188
+ })
189
+
190
+ it("hybrid: Character wired → the token drops to its label, the canonical phrase still attaches it", () => {
191
+ const result = buildImagePrompt({
192
+ prompt: CREATOR_AT_HOME,
193
+ provider: PROVIDER,
194
+ connectedReferences: [kira],
195
+ referenceFormat: "hybrid",
196
+ })
197
+ expect(result.prompt).toBe(`${CREATOR_UNWIRED}\nthe person from reference image A`)
198
+ expect(result.referenceImageUrls).toEqual([kira.url])
199
+ })
200
+
201
+ it("legacy: Character + photo → {image:1} still binds the photo; {image:2} has no image and drops", () => {
202
+ const result = buildImagePrompt({
203
+ prompt: `${CREATOR_AT_HOME} {image:2:face}`,
204
+ provider: PROVIDER,
205
+ connectedReferences: [kira, photo],
206
+ })
207
+ expect(result.prompt.endsWith(
208
+ "\n\nWaist-up photo of Image 2 (person), an adult, standing at home in front of a plain wall, arms relaxed, empty hands. face",
209
+ )).toBe(true)
210
+ expect(result.referenceImageUrls).toEqual([kira.url, photo.url])
211
+ })
212
+
213
+ it("hybrid: Character + photo → {image:1} still binds the photo; {image:2} has no image and drops", () => {
214
+ expect(buildImagePrompt({
215
+ prompt: `${CREATOR_AT_HOME} {image:2:face}`,
216
+ provider: PROVIDER,
217
+ connectedReferences: [kira, photo],
218
+ referenceFormat: "hybrid",
219
+ }).prompt).toBe(
220
+ "Waist-up photo of the person from reference image B, an adult, standing at home in front of a plain wall, arms relaxed, empty hands. face\nthe person from reference image A",
221
+ )
222
+ })
223
+ })
224
+
225
+ describe("unwired {image:N} — the callers on top of the builder", () => {
226
+ it("the segment decomposition still joins to the prompt", () => {
227
+ const result = buildImagePromptSegments(
228
+ { prompt: CREATOR_AT_HOME, provider: PROVIDER, connectedReferences: [] },
229
+ [{ text: CREATOR_AT_HOME, origin: "user" }],
230
+ )
231
+ expect(result.prompt).toBe(CREATOR_UNWIRED)
232
+ expect(result.segments.map((s) => s.text).join("")).toBe(result.prompt)
233
+ })
234
+
235
+ it("assembleImageInput applies it, and a prompt that was only an unwired bare token is empty", () => {
236
+ expect(assembleImageInput({ userPrompt: CREATOR_AT_HOME, provider: PROVIDER, connectedReferences: [] }).prompt)
237
+ .toBe(CREATOR_UNWIRED)
238
+ expect(assembleImageInput({ userPrompt: "{image:1}", provider: PROVIDER, connectedReferences: [] }).prompt).toBe("")
239
+ expect(() => assembleImageInput({
240
+ userPrompt: "{image:1}",
241
+ provider: PROVIDER,
242
+ connectedReferences: [],
243
+ throwOnEmpty: true,
244
+ })).toThrow("No prompt")
245
+ })
246
+ })
247
+
248
+ // The Face Privacy presets put their bare `{image:1}` in the PREFIX, so it
249
+ // reaches the builder through the affix path, not the typed prompt.
250
+ describe("unwired {image:N} — the Face Privacy presets' prefix", () => {
251
+ const presets = GENERATE_IMAGE_PRESETS.filter((p) => p.group === "Face Privacy")
252
+ const prefixed = (data: Readonly<Record<string, unknown>>): string =>
253
+ applyPromptAffixes("", readPromptAffixes(data as Record<string, unknown>), new Map())
254
+
255
+ it("prefix applied, then the builder: dropped with nothing wired, bound with the photo wired", () => {
256
+ expect(presets.map((p) => p.id)).toEqual([
257
+ "generate-image/faceless-3d-head",
258
+ "generate-image/remove-faces",
259
+ "generate-image/transparent-faces",
260
+ ])
261
+ for (const { data } of presets) {
262
+ const prompt = prefixed(data)
263
+ expect(prompt).toContain("{image:1}")
264
+ const provider = data.provider as string
265
+ const negativePrompt = data.negativePrompt as string
266
+ const avoid = `\nAvoid: ${negativePrompt}`
267
+
268
+ const unwired = buildImagePrompt({ prompt, provider, negativePrompt, connectedReferences: [] })
269
+ expect(unwired.prompt).toBe(`${resolveReferenceTokens(prompt, ZERO)}${avoid}`)
270
+ expect(unwired.referenceImageUrls).toBeUndefined()
271
+
272
+ const wired = buildImagePrompt({ prompt, provider, negativePrompt, connectedReferences: [photo] })
273
+ expect(wired.prompt).toBe(`${prompt.replace("{image:1}", "Image 1")}${avoid}`)
274
+ expect(wired.referenceImageUrls).toEqual([photo.url])
275
+
276
+ const wiredHybrid = buildImagePrompt({
277
+ prompt, provider, negativePrompt, connectedReferences: [photo], referenceFormat: "hybrid",
278
+ })
279
+ expect(wiredHybrid.prompt).toContain("reference image A")
280
+ expect(wiredHybrid.prompt).not.toMatch(/\{image:/i)
281
+ }
282
+ })
283
+ })
@@ -130,9 +130,9 @@ describe("mention splice — the {image:N:label} positional pill", () => {
130
130
  )
131
131
  })
132
132
 
133
- it("an out-of-range token stays visible, and its whitespace with it", () => {
134
- expect(buildTokens("a man wearing {image:7:hat} in the park", [hat])).toContain(
135
- "{image:7:hat} in the park",
133
+ it("an out-of-range token drops to its label, and the gap it leaves collapses", () => {
134
+ expect(buildTokens("a man wearing {image:7:hat} in the park", [hat])).toBe(
135
+ "A man wearing hat in the park",
136
136
  )
137
137
  })
138
138
  })
@@ -580,13 +580,16 @@ describe("buildImagePrompt", () => {
580
580
  expect(result.prompt).toContain("Image 1 and Image 2")
581
581
  })
582
582
 
583
- it("does not expand {image:N} for out-of-range index", () => {
583
+ it("drops an out-of-range {image:N} instead of shipping the raw token", () => {
584
+ // No image 3 is wired: the unlabelled token is dropped and the gap it
585
+ // leaves trimmed — the video resolver's rule. A labelled one would keep
586
+ // its label.
584
587
  const result = buildImagePrompt({
585
588
  prompt: "{image:3} is missing",
586
589
  provider: "nano-banana",
587
590
  referenceImageUrls: ["https://a.png", "https://b.png"],
588
591
  })
589
- expect(result.prompt).toContain("{image:3} is missing")
592
+ expect(result.prompt).toBe("is missing")
590
593
  })
591
594
 
592
595
  it("uses ancestor ref count for image expansion when no direct refs", () => {
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { tidyReferenceTokenGaps, unboundReferenceTokenText } from "../unbound-reference-tokens.js"
3
+ import { resolveReferenceTokens } from "../video-reference-resolver.js"
4
+
5
+ describe("unboundReferenceTokenText — what a token with no reference at its slot renders to", () => {
6
+ it("a labelled token becomes its label, verbatim", () => {
7
+ expect(unboundReferenceTokenText("person")).toBe("person")
8
+ expect(unboundReferenceTokenText("clothes and shoes")).toBe("clothes and shoes")
9
+ })
10
+
11
+ it("an unlabelled token renders to nothing", () => {
12
+ expect(unboundReferenceTokenText(undefined)).toBe("")
13
+ })
14
+ })
15
+
16
+ describe("tidyReferenceTokenGaps — the gap a dropped token leaves", () => {
17
+ it("collapses a run of horizontal whitespace to one space and trims", () => {
18
+ expect(tidyReferenceTokenGaps("walk past slowly ")).toBe("walk past slowly")
19
+ expect(tidyReferenceTokenGaps(" \tlead")).toBe("lead")
20
+ })
21
+
22
+ it("keeps newline structure", () => {
23
+ expect(tidyReferenceTokenGaps("a b\n\nc d\ne")).toBe("a b\n\nc d\ne")
24
+ })
25
+ })
26
+
27
+ describe("resolveReferenceTokens renders an out-of-range token through the shared helpers", () => {
28
+ const counts = { image: 1, video: 0, audio: 0 }
29
+ it("labelled → label, unlabelled → dropped, gap collapsed", () => {
30
+ expect(resolveReferenceTokens("a {image:2:dog} and {video:1} run", counts)).toBe("a dog and run")
31
+ })
32
+ })