@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.
@@ -1,7 +1,10 @@
1
1
  // packages/prompts/src/__tests__/creator-presets.test.ts
2
2
  import { describe, it, expect } from "vitest"
3
- import { captionRoutesToRemotion, presetApplyClearKeys } from "@nodaro/shared"
3
+ import { aspectRatioOptionsByKind, captionRoutesToRemotion, MODELS_WITH_REFERENCE_IMAGE_SUPPORT, normalizeNodeModelParams, presetApplyClearKeys, resolveNodeRefs, resolveNormalizedImageGen, type ConnectedReference, type SourceImageSize } from "@nodaro/shared"
4
4
  import { getFactoryPresets, type FactoryPreset } from "../factory-presets.js"
5
+ import { assembleImageInput } from "../assemble-image-input.js"
6
+ import { computeNodePrompt } from "../resolve-prompt.js"
7
+ import { NEUTRAL_PERSON, REALISM_FINISH, REALISM_PASS_INSTRUCTION, REALISM_SKIN, realismEmptyHands, realismViewpoint } from "../realism-wording.js"
5
8
 
6
9
  /** Words that name a recording or viewing device. Plural forms included. */
7
10
  const DEVICE_WORDS = /\b(?:smartphones?|iphones?|phones?|cameras?|lens(?:es)?|webcams?|laptops?|tablets?|screens?|monitors?|displays?|tripods?|selfie[ -]sticks?)\b/i
@@ -149,3 +152,213 @@ describe("Wave 0 — Hook Plate and Body Captions", () => {
149
152
  for (const k of ["backgroundColor", "look", "positionY", "fontWeight", "maxWordsPerLine"]) expect(overClean[k], k).toBeUndefined()
150
153
  })
151
154
  })
155
+
156
+ /** Every prompt-bearing field, joined — what the device and slot checks read. */
157
+ const allText = (d: Readonly<Record<string, unknown>>) =>
158
+ ["promptPrefix", "prompt", "promptSuffix", "negativePrompt"].map((k) => d[k]).filter((v): v is string => typeof v === "string").join(" ")
159
+ const wiredRunPrompts = (d: Readonly<Record<string, unknown>>): Record<"legacy" | "hybrid" | "urlsOnly", string> => {
160
+ const userPrompt = resolveNodeRefs(computeNodePrompt("generate-image", { ...d }, { refMap: new Map() }), new Map())
161
+ const wired = Math.max(0, ...[...userPrompt.matchAll(/\{image:(\d+)[^{}]*\}/gi)].map((m) => Number(m[1])))
162
+ const refs: ConnectedReference[] = Array.from({ length: wired }, (_, i) => ({
163
+ id: `ref-${i + 1}`, defaultName: `Reference ${i + 1}`, source: "wired-image", url: `https://example.com/ref-${i + 1}.jpg`,
164
+ }))
165
+ const provider = d.provider as string
166
+ return {
167
+ legacy: assembleImageInput({ userPrompt, provider, referenceFormat: "legacy", connectedReferences: refs }).prompt,
168
+ hybrid: assembleImageInput({ userPrompt, provider, referenceFormat: "hybrid", connectedReferences: refs }).prompt,
169
+ urlsOnly: assembleImageInput({ userPrompt, provider, extraReferenceImageUrls: refs.map((r) => r.url) }).prompt,
170
+ }
171
+ }
172
+ /** What the image route makes of a preset's levers with `refs` photos connected (and, when known, the size of the photo
173
+ * being transformed): the credit tier, the ratio it runs at and anything it had to change. */
174
+ const imageRouteRun = (node: string, d: Readonly<Record<string, unknown>>, refs: number, sourceImage?: SourceImageSize) =>
175
+ resolveNormalizedImageGen({
176
+ provider: d.provider as string, aspectRatio: d.aspectRatio, resolution: d.resolution, quality: d.quality, refCount: refs, sourceImage,
177
+ // Generate Image moves a text-to-image model to its image-to-image sibling once a photo is connected; Modify Image already names that sibling.
178
+ swapToI2i: node === "generate-image",
179
+ })
180
+ /** The aspect ratio the workflow write step stores for one node of `type` carrying `d`. */
181
+ const storedAspectRatio = (type: string, d: Readonly<Record<string, unknown>>) =>
182
+ normalizeNodeModelParams([{ id: "node", type, data: { ...d } }]).nodes[0]!.data.aspectRatio
183
+ /** A preset's data without the named wording fields — what is left is its levers. */
184
+ const leversOf = (d: Readonly<Record<string, unknown>>, ...wording: string[]) => Object.fromEntries(Object.entries(d).filter(([k]) => !wording.includes(k)))
185
+
186
+ const REALISM_IDS = [
187
+ "unretouched-portrait", "phone-selfie", "candid-snapshot", "mirror-selfie",
188
+ "creator-at-home", "video-call-frame", "macro-skin-study", "product-in-hand",
189
+ ].map((s) => `generate-image/${s}`)
190
+ const REALISM_REFERENCE_IDS = ["generate-image/creator-at-home", "generate-image/product-in-hand"]
191
+ /** Each Realism preset's levers, and the credit tier the image route charges for them (its reference connected where it takes one). */
192
+ const REALISM_RUNS: Readonly<Record<string, readonly [levers: Readonly<Record<string, string>>, creditId: string]>> = {
193
+ "generate-image/unretouched-portrait": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "3:4" }, "gpt-image-2:2K"],
194
+ "generate-image/phone-selfie": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "3:4" }, "gpt-image-2:2K"],
195
+ "generate-image/candid-snapshot": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "3:4" }, "gpt-image-2:2K"],
196
+ "generate-image/mirror-selfie": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "9:16" }, "gpt-image-2:2K"],
197
+ "generate-image/creator-at-home": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "9:16" }, "gpt-image-2-i2i:2K"],
198
+ "generate-image/video-call-frame": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "16:9" }, "gpt-image-2:2K"],
199
+ "generate-image/macro-skin-study": [{ provider: "seedream-5-pro", quality: "high", aspectRatio: "1:1" }, "seedream-5-pro:high"],
200
+ "generate-image/product-in-hand": [{ provider: "gpt-image-2", resolution: "2K", aspectRatio: "3:4" }, "gpt-image-2-i2i:2K"],
201
+ }
202
+
203
+ describe("Wave 1 — Realism & Authentic", () => {
204
+ const group = () => getFactoryPresets("generate-image").filter((p) => p.group === "Realism & Authentic")
205
+
206
+ it("ships exactly these eight presets, in order, each on its model with that model's one lever, and no negativePrompt", () => {
207
+ expect(group().map((p) => p.id)).toEqual(REALISM_IDS)
208
+ expect(Object.keys(REALISM_RUNS)).toEqual(REALISM_IDS)
209
+ for (const p of group()) {
210
+ expect(leversOf(p.data, "prompt"), p.id).toEqual(REALISM_RUNS[p.id]![0])
211
+ expect(p.data.negativePrompt, p.id).toBeUndefined()
212
+ }
213
+ })
214
+
215
+ it("the image route runs each one on its levers unchanged, at its model's tier", () => {
216
+ for (const id of REALISM_IDS) {
217
+ const run = imageRouteRun("generate-image", preset("generate-image", id).data, REALISM_REFERENCE_IDS.includes(id) ? 1 : 0)
218
+ expect(run.adjustments, id).toEqual([])
219
+ expect(run.identifier, id).toBe(REALISM_RUNS[id]![1])
220
+ }
221
+ })
222
+
223
+ it("composes the selected fragments", () => {
224
+ const text = (slug: string) => preset("generate-image", `generate-image/${slug}`).data.prompt as string
225
+ for (const slug of ["unretouched-portrait", "phone-selfie", "candid-snapshot", "mirror-selfie", "creator-at-home", "macro-skin-study"]) expect(text(slug), slug).toContain(REALISM_SKIN)
226
+ for (const slug of ["unretouched-portrait", "phone-selfie", "candid-snapshot", "mirror-selfie", "creator-at-home", "video-call-frame", "product-in-hand"]) expect(text(slug), slug).toContain(REALISM_FINISH)
227
+ expect(text("phone-selfie")).toContain(realismViewpoint(NEUTRAL_PERSON))
228
+ expect(text("phone-selfie")).toContain(realismEmptyHands(NEUTRAL_PERSON))
229
+ })
230
+
231
+ it("names no device — the mirror self-portrait may say phone", () => {
232
+ for (const id of REALISM_IDS) {
233
+ const text = allText(preset("generate-image", id).data)
234
+ // A mirror self-portrait shows the phone in hand.
235
+ const scanned = id === "generate-image/mirror-selfie" ? text.replace(/\bphone\b/gi, "") : text
236
+ expect(scanned, id).not.toMatch(DEVICE_WORDS)
237
+ }
238
+ })
239
+
240
+ it("every subject slot has a default naming an adult; every setting slot has a default", () => {
241
+ for (const id of REALISM_IDS) {
242
+ const text = allText(preset("generate-image", id).data)
243
+ for (const m of text.matchAll(/\{subject([^{}]*)\}/g)) {
244
+ expect(m[1], `${id}: {subject} needs a default`).toMatch(/\|\|/)
245
+ expect(m[1]!.split("||")[1], `${id}: the default names an adult`).toMatch(ADULT)
246
+ }
247
+ for (const m of text.matchAll(/\{setting([^{}]*)\}/g)) expect(m[1], `${id}: {setting} needs a default`).toMatch(/\|\|\s*\S/)
248
+ }
249
+ })
250
+
251
+ it("labels every reference token, and reads whole when the references are wired", () => {
252
+ const TOKEN = /\{(?:image|video|audio):\d+(:[^{}]*)?\}/gi
253
+ const count = (s: string, part: string) => s.split(part).length - 1
254
+ for (const id of REALISM_REFERENCE_IDS) expect(MODELS_WITH_REFERENCE_IMAGE_SUPPORT.has(preset("generate-image", id).data.provider as string), id).toBe(true)
255
+ const withTokens: string[] = []
256
+ for (const id of REALISM_IDS) {
257
+ const d = preset("generate-image", id).data
258
+ const text = allText(d)
259
+ const labels: string[] = []
260
+ for (const m of text.matchAll(TOKEN)) {
261
+ expect(m[1], `${id}: ${m[0]} needs a label`).toBeTruthy()
262
+ labels.push(m[1]!.slice(1))
263
+ }
264
+ if (labels.length > 0) withTokens.push(id)
265
+ const bare = text.replace(TOKEN, "")
266
+ for (const [path, out] of Object.entries(wiredRunPrompts(d))) {
267
+ const legacyReferenceLine = path === "legacy" ? 1 : 0
268
+ for (const label of new Set(labels)) {
269
+ const tokens = labels.filter((l) => l === label).length
270
+ expect(count(out, label), `${id} (${path}): ${label}`).toBeGreaterThanOrEqual(count(bare, label) + tokens + legacyReferenceLine)
271
+ }
272
+ expect(out, `${id} (${path})`).not.toMatch(/ {2}/)
273
+ expect(out, `${id} (${path})`).not.toMatch(/[{}]/)
274
+ expect(out, `${id} (${path})`).not.toMatch(/\bfrom\s+[.,;:!?]/)
275
+ }
276
+ }
277
+ expect(withTokens).toEqual(REALISM_REFERENCE_IDS)
278
+ })
279
+ })
280
+
281
+ describe("Wave 1 — Skin Detail Sheet", () => {
282
+ it("is a Reference Sheet board on seedream-5-pro at high quality, carrying the never-merge clause in pre/post text and no negativePrompt", () => {
283
+ const p = preset("generate-image", "generate-image/skin-detail-sheet")
284
+ expect(p.group).toBe("Reference Sheet")
285
+ expect(leversOf(p.data, "promptPrefix", "promptSuffix")).toEqual({ provider: "seedream-5-pro", quality: "high", aspectRatio: "3:2" })
286
+ expect(MODELS_WITH_REFERENCE_IMAGE_SUPPORT.has(p.data.provider as string)).toBe(true)
287
+ // The connected face photo moves the run to Seedream's image-to-image model, at the same tier.
288
+ const run = imageRouteRun("generate-image", p.data, 1)
289
+ expect(run.adjustments).toEqual([])
290
+ expect(run.identifier).toBe("seedream-5-pro-i2i:high")
291
+ expect(p.data.prompt).toBeUndefined()
292
+ expect(p.data.promptPrefix as string).toContain("never merging or omitting a panel")
293
+ expect(p.data.promptPrefix as string).toContain(REALISM_SKIN)
294
+ expect(p.data.negativePrompt).toBeUndefined()
295
+ expect(allText(p.data)).not.toMatch(DEVICE_WORDS)
296
+ const ids = getFactoryPresets("generate-image").map((x) => x.id)
297
+ expect(ids[ids.indexOf("generate-image/pet-board") + 1]).toBe("generate-image/skin-detail-sheet")
298
+ })
299
+ })
300
+
301
+ describe("Wave 1 — Edits", () => {
302
+ const SLUGS = ["add-skin-realism", "natural-photo-finish", "phone-camera-look", "undo-beauty-filter"]
303
+ const FINISH = { provider: "nano-banana-2-1", aspectRatio: "auto", resolution: "2K" }
304
+ const UNDO = { aspectRatio: "auto", quality: "high" }
305
+ /** Each Edit's levers per node. Modify Image's provider list names Seedream's image-to-image id, not the text-to-image one. */
306
+ const LEVERS: Readonly<Record<string, Readonly<Record<string, Readonly<Record<string, string>>>>>> = {
307
+ "generate-image": { "add-skin-realism": FINISH, "natural-photo-finish": FINISH, "phone-camera-look": FINISH, "undo-beauty-filter": { provider: "seedream-5-pro", ...UNDO } },
308
+ "modify-image": { "add-skin-realism": FINISH, "natural-photo-finish": FINISH, "phone-camera-look": FINISH, "undo-beauty-filter": { provider: "seedream-5-pro-i2i", ...UNDO } },
309
+ }
310
+
311
+ it("ships the four Edits on both image nodes after the Doodle Overlay pair, each on its model and levers, as pre-text only", () => {
312
+ for (const node of ["generate-image", "modify-image"]) {
313
+ const ids = getFactoryPresets(node).map((x) => x.id)
314
+ const i = ids.indexOf(`${node}/doodle-overlay-expressive`)
315
+ expect(ids.slice(i + 1, i + 5)).toEqual(SLUGS.map((s) => `${node}/${s}`))
316
+ for (const s of SLUGS) {
317
+ const p = preset(node, `${node}/${s}`)
318
+ expect(p.group).toBe("Edits")
319
+ expect(typeof p.data.promptPrefix, p.id).toBe("string")
320
+ expect(leversOf(p.data, "promptPrefix"), p.id).toEqual(LEVERS[node]![s])
321
+ // Every Edit asks for "auto", to keep the photo's shape. Undo Beauty Filter's model lists no
322
+ // native "auto"; how each node resolves it is pinned in the Undo Beauty Filter test below.
323
+ expect(p.data.aspectRatio, p.id).toBe("auto")
324
+ if (s !== "undo-beauty-filter") expect(aspectRatioOptionsByKind("image")[p.data.provider as string]?.map((o) => o.value), p.id).toContain("auto")
325
+ expect((p.data.promptPrefix as string).length).toBeLessThanOrEqual(2000)
326
+ expect(allText(p.data), p.id).not.toMatch(DEVICE_WORDS)
327
+ }
328
+ }
329
+ })
330
+
331
+ it("Undo Beauty Filter keeps the photo's shape on both nodes", () => {
332
+ // The write step stores "auto" as written, and the run takes the model's listed ratio nearest the
333
+ // connected photo's size, on Seedream 5 Pro's image-to-image model at high quality.
334
+ const photos: ReadonlyArray<readonly [SourceImageSize, string]> = [[{ width: 1104, height: 1472 }, "3:4"], [{ width: 1600, height: 1000 }, "3:2"]]
335
+ for (const node of ["generate-image", "modify-image"]) {
336
+ const d = preset(node, `${node}/undo-beauty-filter`).data
337
+ expect(storedAspectRatio(node, d), node).toBe("auto")
338
+ for (const [photo, ratio] of photos) {
339
+ const run = imageRouteRun(node, d, 1, photo)
340
+ expect(run.aspectRatio, `${node} ${photo.width}x${photo.height}`).toBe(ratio)
341
+ expect(run.modelId, node).toBe("seedream-5-pro-i2i")
342
+ expect(run.identifier, node).toBe("seedream-5-pro-i2i:high")
343
+ }
344
+ }
345
+ })
346
+
347
+ it("Add Skin Realism is the realism instruction verbatim; the finish edits carry the finish sentence", () => {
348
+ expect(preset("generate-image", "generate-image/add-skin-realism").data.promptPrefix).toBe(REALISM_PASS_INSTRUCTION)
349
+ for (const s of ["natural-photo-finish", "phone-camera-look"])
350
+ expect(preset("generate-image", `generate-image/${s}`).data.promptPrefix as string, s).toContain(REALISM_FINISH)
351
+ })
352
+ })
353
+
354
+ describe("Wave 1 — Realism Audit", () => {
355
+ it("is a structured Creative reading that answers ten yes/no questions and ends with a count", () => {
356
+ const p = preset("image-to-text", "image-to-text/realism-audit")
357
+ expect(p.group).toBe("Creative")
358
+ expect(p.data.detailLevel).toBe("structured")
359
+ const prompt = p.data.customPrompt as string
360
+ expect(prompt).toContain("Tells found: N of 10")
361
+ for (let n = 1; n <= 10; n++) expect(prompt).toContain(`${n}. `)
362
+ expect(prompt.length).toBeLessThanOrEqual(2000)
363
+ })
364
+ })
@@ -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,24 +267,43 @@ 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
  })
273
282
 
274
- it("every generate-image preset has a non-empty negativePrompt", () => {
283
+ // These presets carry their whole wording in the prompt; an added negative list would change the wording they were written with.
284
+ const NEGATIVE_EXEMPT: ReadonlySet<string> = new Set([
285
+ "generate-image/unretouched-portrait", "generate-image/phone-selfie", "generate-image/candid-snapshot",
286
+ "generate-image/mirror-selfie", "generate-image/creator-at-home", "generate-image/video-call-frame",
287
+ "generate-image/macro-skin-study", "generate-image/product-in-hand", "generate-image/skin-detail-sheet",
288
+ "generate-image/add-skin-realism", "generate-image/natural-photo-finish", "generate-image/phone-camera-look",
289
+ "generate-image/undo-beauty-filter",
290
+ ])
291
+
292
+ it("every generate-image preset has a non-empty negativePrompt (except the exempt ones, which carry none)", () => {
275
293
  for (const p of presets) {
294
+ if (NEGATIVE_EXEMPT.has(p.id)) {
295
+ expect(p.data.negativePrompt, `${p.id}: carries its whole wording in the prompt`).toBeUndefined()
296
+ continue
297
+ }
276
298
  const neg = (p.data.negativePrompt as string | undefined) ?? ""
277
299
  expect(neg.trim().length, `${p.id}: missing negativePrompt`).toBeGreaterThan(0)
278
300
  }
279
301
  })
280
302
 
303
+ it("every negative-exempt id exists", () => {
304
+ for (const id of NEGATIVE_EXEMPT) expect(presets.some((p) => p.id === id), id).toBe(true)
305
+ })
306
+
281
307
  it("non-style-pinned generate-image prompts are substantive (no thin one-liners)", () => {
282
308
  for (const p of presets) {
283
309
  if (p.data.style !== undefined) continue // style field carries the look; short prompt is fine
@@ -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
+ })