@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.
- package/dist/index.cjs +339 -134
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -12
- package/dist/index.d.ts +39 -12
- package/dist/index.js +335 -136
- 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__/creator-presets.test.ts +214 -1
- package/src/__tests__/factory-presets.test.ts +38 -12
- 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__/realism-wording.test.ts +41 -0
- package/src/__tests__/unbound-reference-tokens.test.ts +32 -0
- package/src/factory-presets/generate-image.ts +193 -79
- package/src/factory-presets/index.ts +15 -1
- package/src/factory-presets/shared-image.ts +56 -17
- package/src/factory-presets/text.ts +11 -0
- package/src/image-reference-doctrine.ts +7 -3
- package/src/index.ts +1 -0
- package/src/prompt-builder.ts +80 -13
- package/src/prompt-wizard-categories.ts +5 -2
- package/src/provider-prompt-doctrine.ts +2 -2
- package/src/realism-wording.ts +39 -0
- package/src/unbound-reference-tokens.ts +32 -0
- package/src/video-reference-resolver.ts +20 -20
|
@@ -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("
|
|
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,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,
|
|
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
|
})
|
|
273
282
|
|
|
274
|
-
|
|
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
|
+
})
|