@nodaro/prompts 1.11.0 → 1.13.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.
Files changed (36) hide show
  1. package/dist/index.cjs +627 -177
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +726 -33
  4. package/dist/index.d.ts +726 -33
  5. package/dist/index.js +598 -179
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
  9. package/src/__tests__/animal-getters-parity.test.ts +82 -0
  10. package/src/__tests__/assemble-image-input-cap.test.ts +236 -0
  11. package/src/__tests__/assemble-image-input.test.ts +100 -19
  12. package/src/__tests__/assemble-video-input-cap.test.ts +442 -0
  13. package/src/__tests__/assemble-video-input.test.ts +167 -33
  14. package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
  15. package/src/__tests__/entity-convergence-image.test.ts +374 -0
  16. package/src/__tests__/location-convergence-image.test.ts +29 -1
  17. package/src/__tests__/location-default-role-image.test.ts +166 -0
  18. package/src/__tests__/mention-splice-spacing.test.ts +257 -0
  19. package/src/__tests__/prompt-style-section.test.ts +345 -0
  20. package/src/__tests__/read-node-subject.test.ts +140 -0
  21. package/src/__tests__/style-section-boundary.test.ts +179 -0
  22. package/src/__tests__/subject-fold.test.ts +251 -0
  23. package/src/__tests__/subject-registry.test.ts +312 -0
  24. package/src/assemble-image-input.ts +169 -41
  25. package/src/assemble-video-input.ts +200 -25
  26. package/src/direction-registry.ts +116 -28
  27. package/src/hint-shedding.ts +87 -0
  28. package/src/index.ts +3 -0
  29. package/src/parameter-prompt-hint.ts +8 -7
  30. package/src/picker-catalogs.ts +14 -7
  31. package/src/prompt-builder.ts +628 -88
  32. package/src/prompt-hint-join.ts +9 -0
  33. package/src/prompt-style-section.ts +256 -0
  34. package/src/read-node-direction.ts +60 -1
  35. package/src/subject-registry.ts +464 -0
  36. package/src/video-reference-resolver.ts +5 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.11.0",
3
+ "version": "1.13.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.16.0"
23
+ "@nodaro/shared": "^2.18.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,236 @@
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 { renderStyleSection } from "../prompt-style-section.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, order-blind and mid-word.
18
+ *
19
+ * WHAT THE CUT REACHES FIRST is the `[style]` section: the hybrid role phrases
20
+ * splice into the BODY ahead of it (`insertBeforeStyleSection`), so the
21
+ * casualty is a look clause the user picked, severed halfway through, rather
22
+ * than a whole clause dropped cleanly. The assembler knows which clauses are
23
+ * hints (it just rendered them), so it drops them last-folded-first and
24
+ * re-assembles. A larger fold walks the same cut back into the bindings and the
25
+ * prose, which is what the shed keeps out of reach entirely.
26
+ */
27
+
28
+ // A broad but ordinary image direction — the kind a "set every picker" UI
29
+ // emits. Ids that don't resolve contribute nothing (registry tolerance), which
30
+ // is fine: what matters is that the fold is large enough to overflow seedream.
31
+ const DIRECTION = {
32
+ shotSize: "wide-shot",
33
+ angle: "low-angle",
34
+ cameraFormat: "16mm-film",
35
+ lens: "wide-24mm",
36
+ isoValue: "iso-100",
37
+ timeOfDay: "golden-hour",
38
+ lightingStyle: "rembrandt",
39
+ colorLook: "teal-orange",
40
+ atmosphere: ["fog"],
41
+ style: "anime",
42
+ mood: ["happy", "joyful"],
43
+ photographer: ["annie-leibovitz"],
44
+ setting: "forest",
45
+ } as const
46
+
47
+ const IMAGE_HINTS = renderDirectionHints(DIRECTION, {
48
+ surface: "image",
49
+ mode: IMAGE_HINT_MODE_DEFAULT,
50
+ })
51
+
52
+ /**
53
+ * The UNSHED body for a prompt — the oracle for "what the assembler composes
54
+ * before any cap thinking". Every image-surface direction row is `look`, so the
55
+ * whole fold lands in the `[style]` section and the body is the prose alone,
56
+ * trimmed (something folded).
57
+ */
58
+ const unshedBody = (prompt: string): string =>
59
+ `${prompt.trim()}\n\n${renderStyleSection(DIRECTION, {
60
+ surface: "image",
61
+ mode: IMAGE_HINT_MODE_DEFAULT,
62
+ })}`
63
+
64
+ /** The mentioned character — its directive is the FIRST binding in the prompt. */
65
+ const KIRA: ConnectedReference = {
66
+ id: "kira-id",
67
+ defaultName: "Kira",
68
+ source: "wired-character",
69
+ url: "https://r2.example/kira.png",
70
+ characterSlug: "kira",
71
+ variantSlug: undefined,
72
+ characterCanonicalDescription: "a young woman with copper hair",
73
+ variantDescription: null,
74
+ variantDisplayName: "canonical",
75
+ }
76
+
77
+ /** An UNMENTIONED wired location — hybrid renders its role phrase as the last
78
+ * line of the BODY, just ahead of the `[style]` section. */
79
+ const PIER: ConnectedReference = {
80
+ id: "pier-id",
81
+ defaultName: "Pier",
82
+ source: "wired-location",
83
+ url: "https://r2.example/pier.png",
84
+ locationSlug: "pier",
85
+ }
86
+
87
+ /** The mention-resolved character binding, in the middle of the prose. */
88
+ const CHARACTER_BINDING = "the person from reference image A"
89
+ /** The binding the tail cut used to destroy when nothing shed the hints first. */
90
+ const LOCATION_BINDING = "the location from reference image B"
91
+
92
+ /** The whole look section, unshed — what an order-blind cut severs first. */
93
+ const FULL_SECTION = renderStyleSection(DIRECTION, {
94
+ surface: "image",
95
+ mode: IMAGE_HINT_MODE_DEFAULT,
96
+ })
97
+
98
+ /** Prose long enough that prose + directives + the full fold clears 3000. */
99
+ const PROSE = "@kira:1 walks the seawall at dusk. " + "The waves are loud. ".repeat(90)
100
+
101
+ const SEEDREAM_CAP = getMaxImagePromptChars("seedream")
102
+
103
+ const overCapInput = {
104
+ userPrompt: PROSE,
105
+ provider: "seedream",
106
+ connectedReferences: [KIRA, PIER],
107
+ direction: DIRECTION,
108
+ referenceFormat: "hybrid" as const,
109
+ }
110
+
111
+ describe("assembleImageInput — cap-aware hint shedding", () => {
112
+ it("the unordered fold really does overflow seedream (non-vacuity guard)", () => {
113
+ // The oracle for "what the assembler would have produced before": fold every
114
+ // hint, hand it to the builder, let the tail cut decide. If catalog wording
115
+ // ever shrinks enough that this no longer truncates, the scenario below is
116
+ // vacuous and this assertion says so loudly.
117
+ const naive = buildImagePrompt({
118
+ prompt: unshedBody(PROSE),
119
+ provider: "seedream",
120
+ connectedReferences: [KIRA, PIER],
121
+ referenceFormat: "hybrid",
122
+ })
123
+ expect(naive.prompt.endsWith("...")).toBe(true)
124
+ // …and what it cut was the look section, mid-clause — the binding spliced
125
+ // into the body ahead of it and survives the cut it used to die to.
126
+ expect(naive.prompt).toContain(LOCATION_BINDING)
127
+ expect(naive.prompt).not.toContain(FULL_SECTION)
128
+ })
129
+
130
+ it("keeps every reference binding and the full prose, dropping trailing hints", () => {
131
+ const result = assembleImageInput(overCapInput)
132
+
133
+ // Fits WITHOUT a tail cut — the shed resolved the whole overflow.
134
+ expect(result.prompt.length).toBeLessThanOrEqual(SEEDREAM_CAP)
135
+ expect(result.prompt.endsWith("...")).toBe(false)
136
+
137
+ // Both bindings survive: the mention-resolved character phrase inside the
138
+ // prose and the unmentioned location's TRAILING role phrase.
139
+ expect(result.prompt).toContain(CHARACTER_BINDING)
140
+ expect(result.prompt).toContain(LOCATION_BINDING)
141
+ expect(result.referenceImageUrls).toEqual([
142
+ "https://r2.example/kira.png",
143
+ "https://r2.example/pier.png",
144
+ ])
145
+
146
+ // The user's prose survives in full (the mention resolves to its binding;
147
+ // the hint join trims the body's trailing space).
148
+ expect(result.prompt).toContain(PROSE.replace("@kira:1", CHARACTER_BINDING).trim())
149
+
150
+ // The LAST-folded hint clause is gone; the FIRST-folded one stayed. Shedding
151
+ // walks the fold order from the tail, so the dimensions the registry ranks
152
+ // first outlive the ones it ranks last.
153
+ expect(result.prompt).not.toContain(IMAGE_HINTS[IMAGE_HINTS.length - 1])
154
+ expect(result.prompt).toContain(IMAGE_HINTS[0])
155
+ })
156
+
157
+ it("falls back to the builder's clamp when the body overflows on prose alone", () => {
158
+ // Nothing droppable can save a body that blows the cap by itself — the
159
+ // order-blind clamp is still the last resort, unchanged.
160
+ const result = assembleImageInput({
161
+ userPrompt: "x".repeat(SEEDREAM_CAP + 500),
162
+ provider: "seedream",
163
+ direction: DIRECTION,
164
+ })
165
+ expect(result.prompt.length).toBe(SEEDREAM_CAP)
166
+ expect(result.prompt.endsWith("...")).toBe(true)
167
+ })
168
+ })
169
+
170
+ describe("assembleImageInput — under-cap byte parity", () => {
171
+ // The oracle is literally the pre-change implementation: compose every hint,
172
+ // call buildImagePrompt. A prompt that FITS must be byte-identical to it.
173
+ const parityCases: ReadonlyArray<{ name: string; provider: string; prompt: string }> = [
174
+ { name: "high-cap provider with the same maximal fold", provider: "nano-banana-pro", prompt: PROSE },
175
+ { name: "low-cap provider, short prose", provider: "seedream", prompt: "a knight on a hill" },
176
+ ]
177
+
178
+ for (const { name, provider, prompt } of parityCases) {
179
+ it(`is byte-identical to the unordered fold — ${name}`, () => {
180
+ const expected = buildImagePrompt({
181
+ prompt: unshedBody(prompt),
182
+ provider,
183
+ connectedReferences: [KIRA, PIER],
184
+ referenceFormat: "hybrid",
185
+ })
186
+ const actual = assembleImageInput({
187
+ userPrompt: prompt,
188
+ provider,
189
+ connectedReferences: [KIRA, PIER],
190
+ direction: DIRECTION,
191
+ referenceFormat: "hybrid",
192
+ })
193
+ expect(actual).toEqual(expected)
194
+ // Guard the guard: a case that truncated would prove nothing.
195
+ expect(expected.prompt.endsWith("...")).toBe(false)
196
+ })
197
+ }
198
+
199
+ it("leaves the no-direction platform-caller path an exact no-op", () => {
200
+ // No hints → nothing droppable → the prompt reaches the builder verbatim
201
+ // and untrimmed, exactly as before, even on the low-cap provider.
202
+ const result = assembleImageInput({ userPrompt: " a knight ", provider: "seedream" })
203
+ expect(result.prompt).toBe(" a knight ")
204
+ })
205
+ })
206
+
207
+ describe("buildImagePromptWithOverflow", () => {
208
+ it("reports 0 and the identical prompt when the assembly fits", () => {
209
+ const config = { prompt: "a knight on a hill", provider: "seedream" }
210
+ const fitted = buildImagePromptWithOverflow(config)
211
+ expect(fitted.overflowChars).toBe(0)
212
+ const { overflowChars, ...result } = fitted
213
+ expect(result).toEqual(buildImagePrompt(config))
214
+ })
215
+
216
+ it("reports how many characters the cap forced off the tail", () => {
217
+ const config = { prompt: "x".repeat(SEEDREAM_CAP + 250), provider: "seedream" }
218
+ const fitted = buildImagePromptWithOverflow(config)
219
+ expect(fitted.overflowChars).toBe(250)
220
+ const { overflowChars, ...result } = fitted
221
+ expect(result).toEqual(buildImagePrompt(config))
222
+ })
223
+
224
+ it("counts the reserved Style/Avoid suffixes in the overflow", () => {
225
+ // The suffixes are reserved BEFORE the body is cut, so they are part of what
226
+ // must be reclaimed — a caller shedding only `overflowChars` of body still fits.
227
+ const config = {
228
+ prompt: "x".repeat(SEEDREAM_CAP),
229
+ provider: "seedream",
230
+ negativePrompt: "blurry",
231
+ }
232
+ const fitted = buildImagePromptWithOverflow(config)
233
+ expect(fitted.overflowChars).toBe("\nAvoid: blurry".length)
234
+ expect(fitted.prompt.length).toBeLessThanOrEqual(SEEDREAM_CAP)
235
+ })
236
+ })
@@ -49,7 +49,9 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
49
49
  connectedReferences: [ref],
50
50
  direction: { framingId: "medium-shot" },
51
51
  })
52
- expect(result.prompt).toBe(`a knight. ${getFramingPromptHint("medium-shot")}`)
52
+ expect(result.prompt).toBe(
53
+ `a knight\n\n[style]:\n${getFramingPromptHint("medium-shot")}`,
54
+ )
53
55
  expect(result.referenceImageUrls).toEqual(["https://r2.example/hero.png"])
54
56
  })
55
57
 
@@ -59,8 +61,10 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
59
61
  provider: REF_PROVIDER,
60
62
  direction: { framingId: "medium-shot", framingAngleId: "low-angle" },
61
63
  })
64
+ // Two scene-line clauses share one line, `. `-joined.
62
65
  expect(result.prompt).toBe(
63
- `a knight. ${getFramingPromptHint("medium-shot")}. ${getFramingPromptHint("low-angle")}`,
66
+ `a knight\n\n[style]:\n` +
67
+ `${getFramingPromptHint("medium-shot")}. ${getFramingPromptHint("low-angle")}`,
64
68
  )
65
69
  })
66
70
 
@@ -120,7 +124,7 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
120
124
  provider: REF_PROVIDER,
121
125
  direction: { style: "anime" },
122
126
  })
123
- expect(result.prompt).toBe(`a knight. ${getStylePromptHint("anime")}`)
127
+ expect(result.prompt).toBe(`a knight\n\n[style]:\n${getStylePromptHint("anime")}`)
124
128
  })
125
129
 
126
130
  it("blends a multi-pick dimension into ONE clause", () => {
@@ -131,21 +135,37 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
131
135
  provider: REF_PROVIDER,
132
136
  direction: { mood: ["happy", "joyful"] },
133
137
  })
134
- expect(result.prompt).toBe(`a knight. ${blended[0]}`)
138
+ expect(result.prompt).toBe(`a knight\n\n[style]:\n${blended[0]}`)
135
139
  })
136
140
 
137
- it("folds in TABLE order, not the caller's object-literal order", () => {
141
+ it("groups before it orders: the film line leads the scene line", () => {
142
+ // `shotSize` folds at row 2 and `style` at row 22, so table order alone
143
+ // would read the framing clause first; the section's two lines outrank it.
138
144
  const result = assembleImageInput({
139
145
  userPrompt: "a knight",
140
146
  provider: REF_PROVIDER,
141
147
  direction: { style: "anime", shotSize: "wide-shot" },
142
148
  })
143
149
  expect(result.prompt).toBe(
144
- `a knight. ${getFramingPromptHint("wide-shot")}. ${getStylePromptHint("anime")}`,
150
+ `a knight\n\n[style]:\n` +
151
+ `${getStylePromptHint("anime")}\n${getFramingPromptHint("wide-shot")}`,
145
152
  )
146
153
  })
147
154
 
148
- it("keeps the five pre-registry keys byte-identical to the old inlined fold", () => {
155
+ it("folds in TABLE order within a line, not the caller's object-literal order", () => {
156
+ // `shotSize` (row 2) precedes `timeOfDay` (row 14) on the scene line.
157
+ const result = assembleImageInput({
158
+ userPrompt: "a knight",
159
+ provider: REF_PROVIDER,
160
+ direction: { timeOfDay: "golden-hour", shotSize: "wide-shot" },
161
+ })
162
+ expect(result.prompt).toBe(
163
+ `a knight\n\n[style]:\n` +
164
+ `${getFramingPromptHint("wide-shot")}. ${getLightingPromptHint("golden-hour")}`,
165
+ )
166
+ })
167
+
168
+ it("splits the five pre-registry keys across the section's two lines", () => {
149
169
  const direction = {
150
170
  framingId: "wide-shot",
151
171
  framingAngleId: "low-angle",
@@ -158,21 +178,21 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
158
178
  provider: REF_PROVIDER,
159
179
  direction,
160
180
  })
161
- // The exact string the pre-registry `composePromptText` produced: the same
162
- // five clauses, in the same order, joined with the same ". ".
181
+ // `cameraFormatId` is the one film row in the legacy block; the other four
182
+ // fall to the scene line, in the same relative order they always folded in.
163
183
  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(". "),
184
+ "a knight\n\n[style]:\n" +
185
+ `${getCameraFormatPromptHint("16mm-film")}\n` +
186
+ [
187
+ getFramingPromptHint("wide-shot"),
188
+ getFramingPromptHint("low-angle"),
189
+ getLightingPromptHint("golden-hour"),
190
+ getLensPromptHint("wide-24mm"),
191
+ ].join(". "),
172
192
  )
173
193
  })
174
194
 
175
- it("keeps the structured fragment LAST, after every direction clause", () => {
195
+ it("keeps the structured fragment LAST IN THE BODY, ahead of the section", () => {
176
196
  const result = assembleImageInput({
177
197
  userPrompt: "a portrait",
178
198
  provider: REF_PROVIDER,
@@ -180,8 +200,69 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
180
200
  structured: { person: { age: 30, gender: "woman", expression: "calm" } },
181
201
  })
182
202
  expect(result.prompt).toBe(
183
- `a portrait. ${getStylePromptHint("anime")}. Subject: 30 years old, woman, calm expression.`,
203
+ "a portrait. Subject: 30 years old, woman, calm expression." +
204
+ `\n\n[style]:\n${getStylePromptHint("anime")}`,
205
+ )
206
+ })
207
+
208
+ it("emits no section on the image surface only when nothing look-family folds", () => {
209
+ // Every image-surface direction row is `look` (the registry has no
210
+ // image-surface motion row), so a direction that renders ANY clause always
211
+ // opens a section — and a structured-only fold never does.
212
+ expect(
213
+ assembleImageInput({
214
+ userPrompt: "a portrait",
215
+ provider: REF_PROVIDER,
216
+ structured: { person: { age: 30 } },
217
+ }).prompt,
218
+ ).not.toContain("[style]")
219
+ expect(
220
+ assembleImageInput({
221
+ userPrompt: "a portrait",
222
+ provider: REF_PROVIDER,
223
+ direction: { isoValue: "iso-100" },
224
+ }).prompt,
225
+ ).toContain("[style]:\n")
226
+ })
227
+ })
228
+
229
+ /**
230
+ * THE HYBRID LINE-CAPITALIZER. On the hybrid reference format with connected
231
+ * references and NO converged `@`-mention, `buildHybridScene` capitalizes the
232
+ * first alphabetic character of EVERY line of the body. Run over the section
233
+ * that would rewrite the header to `[Style]:` and give every catalog clause a
234
+ * capital it was not written with — so the capitalizer stops at the header.
235
+ */
236
+ describe("assembleImageInput — the hybrid capitalizer stops at the section", () => {
237
+ // A plain wired image: no mention to converge and no canonical role phrase to
238
+ // render, which is what leaves the body UNCONVERGED — the only path where the
239
+ // capitalizer runs at all. (A wired CHARACTER converges via its canonical
240
+ // phrase and skips the capitalizer entirely.)
241
+ const plate: ConnectedReference = {
242
+ id: "plate-id",
243
+ defaultName: "Plate",
244
+ source: "wired-image",
245
+ url: "https://r2.example/plate.png",
246
+ }
247
+
248
+ const hybridInput = {
249
+ userPrompt: "a knight on a hill",
250
+ provider: REF_PROVIDER,
251
+ connectedReferences: [plate],
252
+ referenceFormat: "hybrid" as const,
253
+ direction: { style: "anime", shotSize: "wide-shot" },
254
+ }
255
+
256
+ it("capitalizes the body line (non-vacuity: the capitalizer really runs here)", () => {
257
+ expect(assembleImageInput(hybridInput).prompt).toContain("A knight on a hill")
258
+ })
259
+
260
+ it("leaves the header and every clause line byte-intact", () => {
261
+ const result = assembleImageInput(hybridInput)
262
+ expect(result.prompt).toContain(
263
+ `[style]:\n${getStylePromptHint("anime")}\n${getFramingPromptHint("wide-shot")}`,
184
264
  )
265
+ expect(result.prompt).not.toContain("[Style]")
185
266
  })
186
267
  })
187
268