@nodaro/prompts 1.34.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,41 @@
1
+ // packages/prompts/src/__tests__/realism-wording.test.ts
2
+ import { describe, it, expect } from "vitest"
3
+ import * as wording from "../realism-wording.js"
4
+
5
+ // These strings are read at run time by other Nodaro services; changing one changes their output on the next deploy, so a change needs its owner's review.
6
+ describe("realism wording (frozen)", () => {
7
+ it("exports exactly the selected fragments and the two person functions", () => {
8
+ expect(Object.keys(wording).sort()).toEqual(
9
+ ["NEUTRAL_PERSON", "REALISM_FINISH", "REALISM_PASS_INSTRUCTION", "REALISM_SKIN", "realismEmptyHands", "realismViewpoint"].sort(),
10
+ )
11
+ })
12
+
13
+ it("keeps every fragment's text", () => {
14
+ expect(wording.REALISM_SKIN).toBe(
15
+ "Skin shows its real structure: visible pores that vary in size across the nose and cheeks, fine vellus hair catching the light, faint uneven pigmentation, a little redness around the nostrils, natural oil on the forehead.",
16
+ )
17
+ expect(wording.REALISM_FINISH).toBe(
18
+ "The plain look of an everyday small-sensor snapshot: colour left as it was recorded, deep focus from the nearest shoulder to the background, a fine grain in the darker areas, bright areas that fade rather than clip.",
19
+ )
20
+ expect(wording.REALISM_PASS_INSTRUCTION).toBe(
21
+ "Make this photo look like an unretouched everyday snapshot: skin that shows its pores, its fine hairs and the small changes in tone real skin has, colour left as it was recorded, everything sharp from the shoulders to the background, a little grain in the shadows. Take out anything that makes it look rendered — smoothed, waxy or airbrushed skin, a glow, over-sharpened edges — and add no text or icons. Otherwise it stays the same person in the same frame: the face and its proportions, hair, clothing, pose and background as they are.",
22
+ )
23
+ })
24
+
25
+ it("keeps the viewpoint and empty-hands sentences, neutral and gendered", () => {
26
+ expect(wording.realismViewpoint(wording.NEUTRAL_PERSON)).toBe(
27
+ "The person is looking straight at the viewer from eye level, mid-sentence: their face and shoulders close up, the framing loose and a little crooked, both arms relaxed and down out of the frame. The whole image is the photograph itself, edge to edge, with no text or icons anywhere.",
28
+ )
29
+ expect(wording.realismViewpoint({ subject: "she", possessive: "her" })).toBe(
30
+ "She is looking straight at the viewer from eye level, mid-sentence: her face and shoulders close up, the framing loose and a little crooked, both arms relaxed and down out of the frame. The whole image is the photograph itself, edge to edge, with no text or icons anywhere.",
31
+ )
32
+ expect(wording.realismEmptyHands(wording.NEUTRAL_PERSON)).toBe("Their hands are empty; the person is holding nothing.")
33
+ expect(wording.realismEmptyHands({ subject: "he", possessive: "his" })).toBe("His hands are empty; he is holding nothing.")
34
+ })
35
+
36
+ it("names no device", () => {
37
+ const DEVICE_WORDS = /\b(?:smartphones?|iphones?|phones?|cameras?|lens(?:es)?|webcams?|laptops?|tablets?|screens?|monitors?|displays?|tripods?|selfie[ -]sticks?)\b/i
38
+ for (const s of [wording.REALISM_SKIN, wording.REALISM_FINISH, wording.REALISM_PASS_INSTRUCTION, wording.realismViewpoint(wording.NEUTRAL_PERSON), wording.realismEmptyHands(wording.NEUTRAL_PERSON)])
39
+ expect(s).not.toMatch(DEVICE_WORDS)
40
+ })
41
+ })
@@ -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
+ })