@nodaro/prompts 1.10.0 → 1.12.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 +693 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1084 -27
- package/dist/index.d.ts +1084 -27
- package/dist/index.js +664 -55
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
- package/src/__tests__/animal-getters-parity.test.ts +82 -0
- package/src/__tests__/assemble-image-input-cap.test.ts +212 -0
- package/src/__tests__/assemble-image-input.test.ts +93 -3
- package/src/__tests__/assemble-video-input-cap.test.ts +356 -0
- package/src/__tests__/assemble-video-input.test.ts +301 -0
- package/src/__tests__/direction-hint-token-safety.test.ts +113 -0
- package/src/__tests__/direction-registry.test.ts +393 -0
- package/src/__tests__/entity-convergence-image.test.ts +374 -0
- package/src/__tests__/image-convergence-image.test.ts +370 -0
- package/src/__tests__/location-convergence-image.test.ts +29 -1
- package/src/__tests__/location-default-role-image.test.ts +166 -0
- package/src/__tests__/mention-splice-spacing.test.ts +257 -0
- package/src/__tests__/read-node-direction.test.ts +154 -0
- package/src/__tests__/read-node-subject.test.ts +140 -0
- package/src/__tests__/subject-fold.test.ts +232 -0
- package/src/__tests__/subject-registry.test.ts +312 -0
- package/src/assemble-image-input.ts +160 -58
- package/src/assemble-video-input.ts +244 -0
- package/src/direction-registry.ts +371 -0
- package/src/hint-shedding.ts +68 -0
- package/src/index.ts +10 -2
- package/src/parameter-prompt-hint.ts +8 -7
- package/src/picker-catalogs.ts +14 -7
- package/src/prompt-builder.ts +728 -58
- package/src/prompt-hint-join.ts +30 -0
- package/src/read-node-direction.ts +233 -0
- package/src/subject-registry.ts +464 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodaro/prompts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "vitest run"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@nodaro/shared": "^2.
|
|
23
|
+
"@nodaro/shared": "^2.17.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.5.0",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`byte-identity guarantees > mention-free prompt with a wired creature AND object is unchanged 1`] = `
|
|
4
|
+
"A still life in a dim room
|
|
5
|
+
the creature from reference image A
|
|
6
|
+
the object from reference image B"
|
|
7
|
+
`;
|
|
8
|
+
|
|
9
|
+
exports[`byte-identity guarantees > mention-free prompt with a wired creature is unchanged 1`] = `
|
|
10
|
+
"A wide shot of a lake monster at dusk
|
|
11
|
+
the creature from reference image A"
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
exports[`byte-identity guarantees > no connectedReferences at all is unchanged 1`] = `"a wide shot of a lake at dusk"`;
|
|
15
|
+
|
|
16
|
+
exports[`byte-identity guarantees > prompt with an @-token that matches no entity is unchanged 1`] = `
|
|
17
|
+
"A shot of @dragon:1 over the lake
|
|
18
|
+
the creature from reference image A"
|
|
19
|
+
`;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The animal hint/term getters live in `@nodaro/shared` (`animals.ts`) rather
|
|
3
|
+
* than here, for one hard reason: `catalog-funnel-ratchet.test.ts` derives its
|
|
4
|
+
* watch set from `picker-catalogs.ts`'s uppercase value imports — `ANIMALS`
|
|
5
|
+
* included — scans all of `packages/prompts/src`, and its allowlist can only
|
|
6
|
+
* SHRINK. A getter under `prompts/src` that read `ANIMALS` would be a new
|
|
7
|
+
* offender. Putting them where the catalog lives keeps the funnel honest.
|
|
8
|
+
*
|
|
9
|
+
* That placement costs one thing: `@nodaro/prompts` depends on
|
|
10
|
+
* `@nodaro/shared`, never the reverse, so `getAnimalTerm` cannot call this
|
|
11
|
+
* package's `deriveTerm` and carries a local copy of the four-line derivation.
|
|
12
|
+
* THIS FILE IS THE PIN on that copy — it is the only place that can import both
|
|
13
|
+
* sides. If it fails, the copy in `shared/animals.ts` has drifted from
|
|
14
|
+
* `term.ts`'s `deriveTerm` and must be brought back, not "fixed" by editing the
|
|
15
|
+
* expectation.
|
|
16
|
+
*
|
|
17
|
+
* It also pins the two repointed call sites (the picker-catalog funnel's
|
|
18
|
+
* synthesized `promptHint` and `getParameterPromptHint`'s `animal` case)
|
|
19
|
+
* against the getters, so the phrasing keeps ONE owner.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect } from "vitest"
|
|
22
|
+
import { ANIMALS, getAnimalPromptHint, getAnimalTerm } from "@nodaro/shared"
|
|
23
|
+
import { deriveTerm } from "../term.js"
|
|
24
|
+
import { PICKER_CATALOGS } from "../picker-catalogs.js"
|
|
25
|
+
import { getParameterPromptHint } from "../parameter-prompt-hint.js"
|
|
26
|
+
|
|
27
|
+
const animalCatalog = PICKER_CATALOGS.find((c) => c.nodeType === "animal")
|
|
28
|
+
|
|
29
|
+
describe("animal getters", () => {
|
|
30
|
+
it("phrases the full hint as 'featuring a {label}, {description}'", () => {
|
|
31
|
+
const a = ANIMALS[0]!
|
|
32
|
+
expect(getAnimalPromptHint(a.id)).toBe(
|
|
33
|
+
`featuring a ${a.label.toLowerCase()}, ${a.description}`,
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("returns '' for an unknown, empty, undefined or null id (never throws)", () => {
|
|
38
|
+
for (const id of ["__no_such_animal__", "", undefined, null]) {
|
|
39
|
+
expect(getAnimalPromptHint(id)).toBe("")
|
|
40
|
+
expect(getAnimalTerm(id)).toBe("")
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("derives every term exactly as `deriveTerm` would (the cross-package copy pin)", () => {
|
|
45
|
+
for (const a of ANIMALS) {
|
|
46
|
+
expect(getAnimalTerm(a.id), a.id).toBe(a.term ?? deriveTerm(a.label))
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("gives every catalog entry a non-empty hint and term", () => {
|
|
51
|
+
for (const a of ANIMALS) {
|
|
52
|
+
expect(getAnimalPromptHint(a.id).length, a.id).toBeGreaterThan(0)
|
|
53
|
+
expect(getAnimalTerm(a.id).length, a.id).toBeGreaterThan(0)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe("both repointed call sites read the getters", () => {
|
|
59
|
+
it("the picker-catalog funnel synthesizes every animal option from `getAnimalPromptHint`", () => {
|
|
60
|
+
expect(animalCatalog?.options?.length).toBe(ANIMALS.length)
|
|
61
|
+
for (const opt of animalCatalog!.options!) {
|
|
62
|
+
expect(opt.promptHint, opt.id).toBe(getAnimalPromptHint(opt.id))
|
|
63
|
+
expect(opt.term, opt.id).toBe(getAnimalTerm(opt.id))
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("`getParameterPromptHint` renders an animal node through the getters, per mode", () => {
|
|
68
|
+
const a = ANIMALS[0]!
|
|
69
|
+
expect(getParameterPromptHint({ id: "n1", type: "animal", data: { animal: a.id } })).toBe(
|
|
70
|
+
getAnimalPromptHint(a.id),
|
|
71
|
+
)
|
|
72
|
+
expect(
|
|
73
|
+
getParameterPromptHint({ id: "n1", type: "animal", data: { animal: a.id, hintMode: "compact" } }),
|
|
74
|
+
).toBe(getAnimalTerm(a.id))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("an animal node with an unknown id contributes nothing", () => {
|
|
78
|
+
expect(
|
|
79
|
+
getParameterPromptHint({ id: "n1", type: "animal", data: { animal: "__no_such_animal__" } }),
|
|
80
|
+
).toBe("")
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { assembleImageInput } from "../assemble-image-input.js"
|
|
3
|
+
import { buildImagePrompt, buildImagePromptWithOverflow } from "../prompt-builder.js"
|
|
4
|
+
import { renderDirectionHints, IMAGE_HINT_MODE_DEFAULT } from "../direction-registry.js"
|
|
5
|
+
import { joinPromptHints } from "../prompt-hint-join.js"
|
|
6
|
+
import { getMaxImagePromptChars } from "@nodaro/shared"
|
|
7
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* TRUNCATION ORDERING — `assembleImageInput` sheds its own hint clauses before
|
|
11
|
+
* the provider cap's ORDER-BLIND tail cut can reach anything that matters.
|
|
12
|
+
*
|
|
13
|
+
* The failure this pins (filed as a follow-up on the direction-registry PR): a
|
|
14
|
+
* TRULY maximal image-surface `direction` renders ~3.3K characters of clauses —
|
|
15
|
+
* the broad-but-not-maximal fold below renders ~1.2K, which is already enough
|
|
16
|
+
* to push the assembled prompt past a low-cap provider (seedream = 3000).
|
|
17
|
+
* `buildImagePrompt` then cuts the TAIL — and in the hybrid reference
|
|
18
|
+
* format the trailing canonical role phrase ("the location from reference image
|
|
19
|
+
* B") sits AFTER the folded hints, so the cut severs a REFERENCE BINDING while
|
|
20
|
+
* decorative hint clauses survive. The assembler knows which clauses are hints
|
|
21
|
+
* (it just rendered them), so it drops them last-folded-first and re-assembles.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// A broad but ordinary image direction — the kind a "set every picker" UI
|
|
25
|
+
// emits. Ids that don't resolve contribute nothing (registry tolerance), which
|
|
26
|
+
// is fine: what matters is that the fold is large enough to overflow seedream.
|
|
27
|
+
const DIRECTION = {
|
|
28
|
+
shotSize: "wide-shot",
|
|
29
|
+
angle: "low-angle",
|
|
30
|
+
cameraFormat: "16mm-film",
|
|
31
|
+
lens: "wide-24mm",
|
|
32
|
+
isoValue: "iso-100",
|
|
33
|
+
timeOfDay: "golden-hour",
|
|
34
|
+
lightingStyle: "rembrandt",
|
|
35
|
+
colorLook: "teal-orange",
|
|
36
|
+
atmosphere: ["fog"],
|
|
37
|
+
style: "anime",
|
|
38
|
+
mood: ["happy", "joyful"],
|
|
39
|
+
photographer: ["annie-leibovitz"],
|
|
40
|
+
setting: "forest",
|
|
41
|
+
} as const
|
|
42
|
+
|
|
43
|
+
const IMAGE_HINTS = renderDirectionHints(DIRECTION, {
|
|
44
|
+
surface: "image",
|
|
45
|
+
mode: IMAGE_HINT_MODE_DEFAULT,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** The mentioned character — its directive is the FIRST binding in the prompt. */
|
|
49
|
+
const KIRA: ConnectedReference = {
|
|
50
|
+
id: "kira-id",
|
|
51
|
+
defaultName: "Kira",
|
|
52
|
+
source: "wired-character",
|
|
53
|
+
url: "https://r2.example/kira.png",
|
|
54
|
+
characterSlug: "kira",
|
|
55
|
+
variantSlug: undefined,
|
|
56
|
+
characterCanonicalDescription: "a young woman with copper hair",
|
|
57
|
+
variantDescription: null,
|
|
58
|
+
variantDisplayName: "canonical",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An UNMENTIONED wired location — hybrid renders its role phrase at the very
|
|
62
|
+
* END of the prompt, behind every folded hint. The order-blind casualty. */
|
|
63
|
+
const PIER: ConnectedReference = {
|
|
64
|
+
id: "pier-id",
|
|
65
|
+
defaultName: "Pier",
|
|
66
|
+
source: "wired-location",
|
|
67
|
+
url: "https://r2.example/pier.png",
|
|
68
|
+
locationSlug: "pier",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The mention-resolved character binding, in the middle of the prose. */
|
|
72
|
+
const CHARACTER_BINDING = "the person from reference image A"
|
|
73
|
+
/** The binding the tail cut destroys when nothing sheds the hints first. */
|
|
74
|
+
const LOCATION_BINDING = "the location from reference image B"
|
|
75
|
+
|
|
76
|
+
/** Prose long enough that prose + directives + the full fold clears 3000. */
|
|
77
|
+
const PROSE = "@kira:1 walks the seawall at dusk. " + "The waves are loud. ".repeat(90)
|
|
78
|
+
|
|
79
|
+
const SEEDREAM_CAP = getMaxImagePromptChars("seedream")
|
|
80
|
+
|
|
81
|
+
const overCapInput = {
|
|
82
|
+
userPrompt: PROSE,
|
|
83
|
+
provider: "seedream",
|
|
84
|
+
connectedReferences: [KIRA, PIER],
|
|
85
|
+
direction: DIRECTION,
|
|
86
|
+
referenceFormat: "hybrid" as const,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("assembleImageInput — cap-aware hint shedding", () => {
|
|
90
|
+
it("the unordered fold really does overflow seedream (non-vacuity guard)", () => {
|
|
91
|
+
// The oracle for "what the assembler would have produced before": fold every
|
|
92
|
+
// hint, hand it to the builder, let the tail cut decide. If catalog wording
|
|
93
|
+
// ever shrinks enough that this no longer truncates, the scenario below is
|
|
94
|
+
// vacuous and this assertion says so loudly.
|
|
95
|
+
const naive = buildImagePrompt({
|
|
96
|
+
prompt: joinPromptHints(PROSE, IMAGE_HINTS),
|
|
97
|
+
provider: "seedream",
|
|
98
|
+
connectedReferences: [KIRA, PIER],
|
|
99
|
+
referenceFormat: "hybrid",
|
|
100
|
+
})
|
|
101
|
+
expect(naive.prompt.endsWith("...")).toBe(true)
|
|
102
|
+
// …and what it cut was the reference binding, not the decorative tail.
|
|
103
|
+
expect(naive.prompt).not.toContain(LOCATION_BINDING)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("keeps every reference binding and the full prose, dropping trailing hints", () => {
|
|
107
|
+
const result = assembleImageInput(overCapInput)
|
|
108
|
+
|
|
109
|
+
// Fits WITHOUT a tail cut — the shed resolved the whole overflow.
|
|
110
|
+
expect(result.prompt.length).toBeLessThanOrEqual(SEEDREAM_CAP)
|
|
111
|
+
expect(result.prompt.endsWith("...")).toBe(false)
|
|
112
|
+
|
|
113
|
+
// Both bindings survive: the mention-resolved character phrase inside the
|
|
114
|
+
// prose and the unmentioned location's TRAILING role phrase.
|
|
115
|
+
expect(result.prompt).toContain(CHARACTER_BINDING)
|
|
116
|
+
expect(result.prompt).toContain(LOCATION_BINDING)
|
|
117
|
+
expect(result.referenceImageUrls).toEqual([
|
|
118
|
+
"https://r2.example/kira.png",
|
|
119
|
+
"https://r2.example/pier.png",
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
// The user's prose survives in full (the mention resolves to its binding;
|
|
123
|
+
// the hint join trims the body's trailing space).
|
|
124
|
+
expect(result.prompt).toContain(PROSE.replace("@kira:1", CHARACTER_BINDING).trim())
|
|
125
|
+
|
|
126
|
+
// The LAST-folded hint clause is gone; the FIRST-folded one stayed. Shedding
|
|
127
|
+
// walks the fold order from the tail, so the dimensions the registry ranks
|
|
128
|
+
// first outlive the ones it ranks last.
|
|
129
|
+
expect(result.prompt).not.toContain(IMAGE_HINTS[IMAGE_HINTS.length - 1])
|
|
130
|
+
expect(result.prompt).toContain(IMAGE_HINTS[0])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("falls back to the builder's clamp when the body overflows on prose alone", () => {
|
|
134
|
+
// Nothing droppable can save a body that blows the cap by itself — the
|
|
135
|
+
// order-blind clamp is still the last resort, unchanged.
|
|
136
|
+
const result = assembleImageInput({
|
|
137
|
+
userPrompt: "x".repeat(SEEDREAM_CAP + 500),
|
|
138
|
+
provider: "seedream",
|
|
139
|
+
direction: DIRECTION,
|
|
140
|
+
})
|
|
141
|
+
expect(result.prompt.length).toBe(SEEDREAM_CAP)
|
|
142
|
+
expect(result.prompt.endsWith("...")).toBe(true)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe("assembleImageInput — under-cap byte parity", () => {
|
|
147
|
+
// The oracle is literally the pre-change implementation: compose every hint,
|
|
148
|
+
// call buildImagePrompt. A prompt that FITS must be byte-identical to it.
|
|
149
|
+
const parityCases: ReadonlyArray<{ name: string; provider: string; prompt: string }> = [
|
|
150
|
+
{ name: "high-cap provider with the same maximal fold", provider: "nano-banana-pro", prompt: PROSE },
|
|
151
|
+
{ name: "low-cap provider, short prose", provider: "seedream", prompt: "a knight on a hill" },
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
for (const { name, provider, prompt } of parityCases) {
|
|
155
|
+
it(`is byte-identical to the unordered fold — ${name}`, () => {
|
|
156
|
+
const expected = buildImagePrompt({
|
|
157
|
+
prompt: joinPromptHints(prompt, IMAGE_HINTS),
|
|
158
|
+
provider,
|
|
159
|
+
connectedReferences: [KIRA, PIER],
|
|
160
|
+
referenceFormat: "hybrid",
|
|
161
|
+
})
|
|
162
|
+
const actual = assembleImageInput({
|
|
163
|
+
userPrompt: prompt,
|
|
164
|
+
provider,
|
|
165
|
+
connectedReferences: [KIRA, PIER],
|
|
166
|
+
direction: DIRECTION,
|
|
167
|
+
referenceFormat: "hybrid",
|
|
168
|
+
})
|
|
169
|
+
expect(actual).toEqual(expected)
|
|
170
|
+
// Guard the guard: a case that truncated would prove nothing.
|
|
171
|
+
expect(expected.prompt.endsWith("...")).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
it("leaves the no-direction platform-caller path an exact no-op", () => {
|
|
176
|
+
// No hints → nothing droppable → the prompt reaches the builder verbatim
|
|
177
|
+
// and untrimmed, exactly as before, even on the low-cap provider.
|
|
178
|
+
const result = assembleImageInput({ userPrompt: " a knight ", provider: "seedream" })
|
|
179
|
+
expect(result.prompt).toBe(" a knight ")
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe("buildImagePromptWithOverflow", () => {
|
|
184
|
+
it("reports 0 and the identical prompt when the assembly fits", () => {
|
|
185
|
+
const config = { prompt: "a knight on a hill", provider: "seedream" }
|
|
186
|
+
const fitted = buildImagePromptWithOverflow(config)
|
|
187
|
+
expect(fitted.overflowChars).toBe(0)
|
|
188
|
+
const { overflowChars, ...result } = fitted
|
|
189
|
+
expect(result).toEqual(buildImagePrompt(config))
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("reports how many characters the cap forced off the tail", () => {
|
|
193
|
+
const config = { prompt: "x".repeat(SEEDREAM_CAP + 250), provider: "seedream" }
|
|
194
|
+
const fitted = buildImagePromptWithOverflow(config)
|
|
195
|
+
expect(fitted.overflowChars).toBe(250)
|
|
196
|
+
const { overflowChars, ...result } = fitted
|
|
197
|
+
expect(result).toEqual(buildImagePrompt(config))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("counts the reserved Style/Avoid suffixes in the overflow", () => {
|
|
201
|
+
// The suffixes are reserved BEFORE the body is cut, so they are part of what
|
|
202
|
+
// must be reclaimed — a caller shedding only `overflowChars` of body still fits.
|
|
203
|
+
const config = {
|
|
204
|
+
prompt: "x".repeat(SEEDREAM_CAP),
|
|
205
|
+
provider: "seedream",
|
|
206
|
+
negativePrompt: "blurry",
|
|
207
|
+
}
|
|
208
|
+
const fitted = buildImagePromptWithOverflow(config)
|
|
209
|
+
expect(fitted.overflowChars).toBe("\nAvoid: blurry".length)
|
|
210
|
+
expect(fitted.prompt.length).toBeLessThanOrEqual(SEEDREAM_CAP)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -3,6 +3,10 @@ import { assembleImageInput } from "../assemble-image-input.js"
|
|
|
3
3
|
import { buildImagePrompt } from "../prompt-builder.js"
|
|
4
4
|
import { getFramingPromptHint } from "../framing.js"
|
|
5
5
|
import { getLightingPromptHint } from "../lighting.js"
|
|
6
|
+
import { getLensPromptHint } from "../lens.js"
|
|
7
|
+
import { getCameraFormatPromptHint } from "../camera-format.js"
|
|
8
|
+
import { getStylePromptHint } from "../style.js"
|
|
9
|
+
import { buildMoodHints } from "../mood.js"
|
|
6
10
|
import type { ConnectedReference } from "@nodaro/shared"
|
|
7
11
|
|
|
8
12
|
/**
|
|
@@ -11,9 +15,9 @@ import type { ConnectedReference } from "@nodaro/shared"
|
|
|
11
15
|
* `generate-image` assembly through it. These tests pin BOTH layers:
|
|
12
16
|
* (a) the id-based composition (direction / structured) — ported from
|
|
13
17
|
* Studio's `assembly.test.ts` as the oracle, and
|
|
14
|
-
* (b) the BY-CONSTRUCTION PARITY the caller refactor relies on:
|
|
15
|
-
* direction/structured, the wrapper === the old inline
|
|
16
|
-
* call + empty-check, byte-for-byte.
|
|
18
|
+
* (b) the BY-CONSTRUCTION PARITY the caller refactor relies on: for a node
|
|
19
|
+
* that carries no direction/structured, the wrapper === the old inline
|
|
20
|
+
* `buildImagePrompt` call + empty-check, byte-for-byte.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
// flux-2-max supports reference images (used to assert refs survive the gate).
|
|
@@ -93,6 +97,92 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
|
|
|
93
97
|
})
|
|
94
98
|
expect(result.prompt).toBe("a portrait. Subject: 30 years old, woman, calm expression.")
|
|
95
99
|
})
|
|
100
|
+
|
|
101
|
+
// ── The direction registry (the fold moved into `direction-registry.ts`) ──
|
|
102
|
+
|
|
103
|
+
it("returns the prompt VERBATIM and UNTRIMMED for a direction that renders nothing", () => {
|
|
104
|
+
// The no-op branch is what the platform-caller parity contract rests on: an
|
|
105
|
+
// empty (or all-empty-valued) `direction` must not trip the join, or the
|
|
106
|
+
// prompt would silently get trimmed.
|
|
107
|
+
for (const direction of [{}, { style: "" }, { mood: [] }, { style: "__no_such_style__" }]) {
|
|
108
|
+
const result = assembleImageInput({
|
|
109
|
+
userPrompt: " a knight \n",
|
|
110
|
+
provider: REF_PROVIDER,
|
|
111
|
+
direction,
|
|
112
|
+
})
|
|
113
|
+
expect(result.prompt).toBe(" a knight \n")
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("folds a registry key that predates no legacy field (style) end to end", () => {
|
|
118
|
+
const result = assembleImageInput({
|
|
119
|
+
userPrompt: "a knight",
|
|
120
|
+
provider: REF_PROVIDER,
|
|
121
|
+
direction: { style: "anime" },
|
|
122
|
+
})
|
|
123
|
+
expect(result.prompt).toBe(`a knight. ${getStylePromptHint("anime")}`)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("blends a multi-pick dimension into ONE clause", () => {
|
|
127
|
+
const blended = buildMoodHints({ mood: ["happy", "joyful"] }, "full")
|
|
128
|
+
expect(blended).toHaveLength(1)
|
|
129
|
+
const result = assembleImageInput({
|
|
130
|
+
userPrompt: "a knight",
|
|
131
|
+
provider: REF_PROVIDER,
|
|
132
|
+
direction: { mood: ["happy", "joyful"] },
|
|
133
|
+
})
|
|
134
|
+
expect(result.prompt).toBe(`a knight. ${blended[0]}`)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("folds in TABLE order, not the caller's object-literal order", () => {
|
|
138
|
+
const result = assembleImageInput({
|
|
139
|
+
userPrompt: "a knight",
|
|
140
|
+
provider: REF_PROVIDER,
|
|
141
|
+
direction: { style: "anime", shotSize: "wide-shot" },
|
|
142
|
+
})
|
|
143
|
+
expect(result.prompt).toBe(
|
|
144
|
+
`a knight. ${getFramingPromptHint("wide-shot")}. ${getStylePromptHint("anime")}`,
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("keeps the five pre-registry keys byte-identical to the old inlined fold", () => {
|
|
149
|
+
const direction = {
|
|
150
|
+
framingId: "wide-shot",
|
|
151
|
+
framingAngleId: "low-angle",
|
|
152
|
+
lightingId: "golden-hour",
|
|
153
|
+
lensId: "wide-24mm",
|
|
154
|
+
cameraFormatId: "16mm-film",
|
|
155
|
+
}
|
|
156
|
+
const result = assembleImageInput({
|
|
157
|
+
userPrompt: "a knight",
|
|
158
|
+
provider: REF_PROVIDER,
|
|
159
|
+
direction,
|
|
160
|
+
})
|
|
161
|
+
// The exact string the pre-registry `composePromptText` produced: the same
|
|
162
|
+
// five clauses, in the same order, joined with the same ". ".
|
|
163
|
+
expect(result.prompt).toBe(
|
|
164
|
+
[
|
|
165
|
+
"a knight",
|
|
166
|
+
getFramingPromptHint("wide-shot"),
|
|
167
|
+
getFramingPromptHint("low-angle"),
|
|
168
|
+
getLightingPromptHint("golden-hour"),
|
|
169
|
+
getLensPromptHint("wide-24mm"),
|
|
170
|
+
getCameraFormatPromptHint("16mm-film"),
|
|
171
|
+
].join(". "),
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("keeps the structured fragment LAST, after every direction clause", () => {
|
|
176
|
+
const result = assembleImageInput({
|
|
177
|
+
userPrompt: "a portrait",
|
|
178
|
+
provider: REF_PROVIDER,
|
|
179
|
+
direction: { style: "anime" },
|
|
180
|
+
structured: { person: { age: 30, gender: "woman", expression: "calm" } },
|
|
181
|
+
})
|
|
182
|
+
expect(result.prompt).toBe(
|
|
183
|
+
`a portrait. ${getStylePromptHint("anime")}. Subject: 30 years old, woman, calm expression.`,
|
|
184
|
+
)
|
|
185
|
+
})
|
|
96
186
|
})
|
|
97
187
|
|
|
98
188
|
describe("assembleImageInput — empty-prompt throw (opt-in)", () => {
|