@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.
- package/dist/index.cjs +132 -110
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -10
- package/dist/index.d.ts +19 -10
- package/dist/index.js +132 -110
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/image-bound-reference-tokens-golden.test.ts.snap +1273 -0
- package/src/__tests__/factory-presets.test.ts +20 -11
- package/src/__tests__/image-bound-reference-tokens-golden.test.ts +90 -0
- package/src/__tests__/image-unwired-reference-tokens.test.ts +283 -0
- package/src/__tests__/mention-splice-spacing.test.ts +3 -3
- package/src/__tests__/prompt-builder.test.ts +5 -2
- package/src/__tests__/unbound-reference-tokens.test.ts +32 -0
- package/src/factory-presets/generate-image.ts +79 -79
- package/src/factory-presets/shared-image.ts +14 -17
- package/src/image-reference-doctrine.ts +7 -3
- package/src/prompt-builder.ts +80 -13
- package/src/prompt-wizard-categories.ts +3 -0
- package/src/unbound-reference-tokens.ts +32 -0
- package/src/video-reference-resolver.ts +20 -20
|
@@ -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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
238
|
-
|
|
239
|
-
|
|
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("
|
|
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("
|
|
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,
|
|
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
|
-
|
|
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
|
|
134
|
-
expect(buildTokens("a man wearing {image:7:hat} in the park", [hat])).
|
|
135
|
-
"
|
|
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("
|
|
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).
|
|
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
|
+
})
|