@nodaro/prompts 1.11.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.
@@ -0,0 +1,257 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildImagePrompt } from "../prompt-builder.js"
3
+ import type { ConnectedReference } from "@nodaro/shared"
4
+
5
+ /**
6
+ * Seam whitespace at a resolved mention (image path).
7
+ *
8
+ * A live prod payload assembled to
9
+ *
10
+ * "the person from reference image A and reference image C walking …"
11
+ *
12
+ * — TWO spaces after the character phrase. An editor serializes a mention chip
13
+ * as its token plus its own trailing space, and the prose that follows carries
14
+ * the space the author typed, so an ordinary sentence reaches the route as
15
+ * `@panda:1 and @panda2:2 …`. The VIDEO core never shows this: every return of
16
+ * `resolveVideoReferenceCore` runs `resolveReferenceTokens`, whose
17
+ * `[^\S\r\n]{2,}` collapse tidies the gap. The image path had no such tidy, so
18
+ * the doubled space landed in the model's prompt exactly where the reference is
19
+ * being described.
20
+ *
21
+ * `spliceMentionPhrase` now collapses the horizontal whitespace on either side
22
+ * of each splice seam — scoped to the seam, so prose the author double-spaced
23
+ * elsewhere is left alone and an already-single-spaced prompt is byte-identical.
24
+ */
25
+
26
+ const panda: ConnectedReference = {
27
+ id: "c1", defaultName: "Panda", source: "wired-character",
28
+ url: "https://cdn/panda.png", characterSlug: "panda",
29
+ }
30
+ const panda2: ConnectedReference = {
31
+ id: "n2", defaultName: "Panda2", source: "wired-image", url: "https://cdn/panda2.png",
32
+ }
33
+ const beach: ConnectedReference = {
34
+ id: "l1", defaultName: "Beach", source: "wired-location",
35
+ url: "https://cdn/beach.png", locationSlug: "beach",
36
+ }
37
+
38
+ const REFS = [panda, panda2, beach]
39
+
40
+ /** Mention URLs merge character → location → image, so the letters are
41
+ * A = the character, B = the location, C = the named image. */
42
+ const EXPECTED =
43
+ "the person from reference image A and reference image C walking in "
44
+ + "the location from reference image B at dusk"
45
+
46
+ const build = (prompt: string) => buildImagePrompt({
47
+ prompt,
48
+ connectedReferences: REFS,
49
+ provider: "nano-banana-pro",
50
+ referenceFormat: "hybrid",
51
+ }).prompt
52
+
53
+ describe("mention splice — seam whitespace", () => {
54
+ it("Tal's shape: character + image + location in one sentence, single-spaced throughout", () => {
55
+ expect(build("@panda:1 and @panda2:2 walking in @beach:3 at dusk")).toBe(EXPECTED)
56
+ })
57
+
58
+ it("the already-single-spaced prompt is byte-identical (the collapse needs a 2+ run)", () => {
59
+ expect(build("@panda:1 and @panda2:2 walking in @beach:3 at dusk")).toBe(EXPECTED)
60
+ })
61
+
62
+ it("collapses on the LEADING side of a seam too", () => {
63
+ expect(build("@panda:1 and @panda2:2 walking in @beach:3 at dusk")).toBe(EXPECTED)
64
+ })
65
+
66
+ it("never emits a doubled space, whichever side of the chip carries it", () => {
67
+ for (const prompt of [
68
+ "@panda:1 and @panda2:2 walking in @beach:3 at dusk",
69
+ "@panda:1 and @panda2:2 walking in @beach:3 at dusk",
70
+ "@panda:1 and @panda2:2 walking in @beach:3 at dusk",
71
+ ]) {
72
+ expect(build(prompt)).toBe(EXPECTED)
73
+ }
74
+ })
75
+
76
+ it("leaves a doubled space the author put in their own prose alone (seam-scoped, not a body tidy)", () => {
77
+ expect(build("@panda:1 walks slowly past @beach:3")).toBe(
78
+ "the person from reference image A walks slowly past the location from reference image B",
79
+ )
80
+ })
81
+
82
+ it("preserves newlines — the collapse class is horizontal-only", () => {
83
+ expect(build("@panda:1 runs\n\n@beach:3 at dusk")).toBe(
84
+ "the person from reference image A runs\n\nthe location from reference image B at dusk",
85
+ )
86
+ })
87
+ })
88
+
89
+ // The `{image:N:label}` positional pill is a mention chip like any other:
90
+ // `buildRefPillNodes` appends its own trailing space to EVERY pill it builds,
91
+ // the positional `imageRef` node included. Its hybrid expansion used a raw
92
+ // `.replace`, so the same doubled space survived on this path after the three
93
+ // `@`-mention resolvers were fixed.
94
+ describe("mention splice — the {image:N:label} positional pill", () => {
95
+ const hat: ConnectedReference = {
96
+ id: "n1", defaultName: "Hat", source: "wired-image", url: "https://cdn/hat.png",
97
+ }
98
+ const coat: ConnectedReference = {
99
+ id: "n2", defaultName: "Coat", source: "wired-image", url: "https://cdn/coat.png",
100
+ }
101
+ const buildTokens = (prompt: string, refs: readonly ConnectedReference[]) => buildImagePrompt({
102
+ prompt,
103
+ connectedReferences: [...refs],
104
+ referenceImageUrls: refs.map((r) => r.url!),
105
+ provider: "nano-banana-pro",
106
+ referenceFormat: "hybrid",
107
+ }).prompt
108
+
109
+ it("collapses the trailing seam the pill's own space creates", () => {
110
+ expect(buildTokens("a man wearing {image:1:hat} in the park", [hat])).toBe(
111
+ "A man wearing the hat from reference image A in the park",
112
+ )
113
+ })
114
+
115
+ it("the already-single-spaced prompt is byte-identical", () => {
116
+ expect(buildTokens("a man wearing {image:1:hat} in the park", [hat])).toBe(
117
+ "A man wearing the hat from reference image A in the park",
118
+ )
119
+ })
120
+
121
+ it("two pills in one sentence both splice cleanly (right-to-left offsets stay valid)", () => {
122
+ expect(buildTokens("a man wearing {image:1:hat} and {image:2:coat} walks", [hat, coat])).toBe(
123
+ "A man wearing the hat from reference image A and the coat from reference image B walks",
124
+ )
125
+ })
126
+
127
+ it("leaves a doubled space the author put in their own prose alone", () => {
128
+ expect(buildTokens("{image:1:hat} worn loosely", [hat])).toBe(
129
+ "The hat from reference image A worn loosely",
130
+ )
131
+ })
132
+
133
+ it("an out-of-range token stays visible, and its whitespace with it", () => {
134
+ expect(buildTokens("a man wearing {image:7:hat} in the park", [hat])).toContain(
135
+ "{image:7:hat} in the park",
136
+ )
137
+ })
138
+ })
139
+
140
+ // Indentation is structure, not a seam. A run that OPENS a line is the author's
141
+ // layout (a shot list, a numbered beat sheet), so the leading collapse is
142
+ // anchored to prose on the same line via `(?<=\S)`. Without that anchor a
143
+ // mention starting an indented line flattened the indent to a single space —
144
+ // the same class of damage as merging paragraphs, and it would have made the
145
+ // "an already-single-spaced prompt is byte-identical" claim false for every
146
+ // multi-line prompt.
147
+ describe("mention splice — line-initial indentation survives", () => {
148
+ /** One character ref only, so no unmentioned ref appends a canonical phrase
149
+ * and the assertions can be byte-exact on the multi-line shape. */
150
+ const buildOne = (prompt: string) => buildImagePrompt({
151
+ prompt,
152
+ connectedReferences: [panda],
153
+ provider: "nano-banana-pro",
154
+ referenceFormat: "hybrid",
155
+ }).prompt
156
+
157
+ it("a mention that opens an indented line keeps the indent verbatim", () => {
158
+ expect(buildOne("Scene:\n @panda:1 stands")).toBe(
159
+ "Scene:\n the person from reference image A stands",
160
+ )
161
+ })
162
+
163
+ it("indent preserved AND the trailing seam still collapses on the same mention", () => {
164
+ expect(buildOne("Scene:\n @panda:1 stands")).toBe(
165
+ "Scene:\n the person from reference image A stands",
166
+ )
167
+ })
168
+
169
+ it("a leading run at the very start of the prompt is indentation too", () => {
170
+ expect(buildOne(" @panda:1 stands")).toBe(
171
+ " the person from reference image A stands",
172
+ )
173
+ })
174
+
175
+ it("mid-line prose before the run still collapses (the anchor is `\\S`, not `^`)", () => {
176
+ expect(buildOne("Scene:\n a man and @panda:1 stands")).toBe(
177
+ "Scene:\n a man and the person from reference image A stands",
178
+ )
179
+ })
180
+ })
181
+
182
+ // Wired CREATURE / OBJECT mentions (`@<name-slug>:<index>[:<role>]`) are chips
183
+ // too — `buildRefPillNodes` gives them the same trailing space every other pill
184
+ // gets — so their resolver splices through `spliceMentionPhrase` alongside the
185
+ // character, location and named-image ones. Without that, the seam bug would
186
+ // have been fixed for three mention kinds and reintroduced by the fourth.
187
+ describe("mention splice — wired creature / object mentions", () => {
188
+ const nessie: ConnectedReference = {
189
+ id: "cr1", defaultName: "Nessie", source: "wired-creature", url: "https://cdn/nessie.png",
190
+ }
191
+ const chair: ConnectedReference = {
192
+ id: "ob1", defaultName: "Chair", source: "wired-object", url: "https://cdn/chair.png",
193
+ }
194
+
195
+ /** BOTH entities are mentioned in every case, so neither appends a trailing
196
+ * canonical phrase and the assertions stay byte-exact on one line. */
197
+ const buildEntities = (prompt: string) => buildImagePrompt({
198
+ prompt,
199
+ connectedReferences: [nessie, chair],
200
+ provider: "nano-banana-pro",
201
+ referenceFormat: "hybrid",
202
+ }).prompt
203
+
204
+ const ENTITIES_EXPECTED =
205
+ "the creature from reference image A looms over "
206
+ + "the object from reference image B in the fog"
207
+
208
+ it("collapses the seam whitespace the chips' own trailing spaces create", () => {
209
+ expect(buildEntities("@nessie:1 looms over @chair:2 in the fog")).toBe(ENTITIES_EXPECTED)
210
+ })
211
+
212
+ it("the already-single-spaced prompt is byte-identical (the collapse needs a 2+ run)", () => {
213
+ expect(buildEntities("@nessie:1 looms over @chair:2 in the fog")).toBe(ENTITIES_EXPECTED)
214
+ })
215
+
216
+ it("collapses on the LEADING side of an entity seam too", () => {
217
+ expect(buildEntities("@nessie:1 looms over @chair:2 in the fog")).toBe(ENTITIES_EXPECTED)
218
+ })
219
+
220
+ it("never emits a doubled space, whichever side of the chip carries it", () => {
221
+ for (const prompt of [
222
+ "@nessie:1 looms over @chair:2 in the fog",
223
+ "@nessie:1 looms over @chair:2 in the fog",
224
+ "@nessie:1 looms over @chair:2 in the fog",
225
+ ]) {
226
+ expect(buildEntities(prompt)).toBe(ENTITIES_EXPECTED)
227
+ }
228
+ })
229
+
230
+ it("a ROLE segment splices the same way", () => {
231
+ expect(buildEntities("@nessie:1:markings over @chair:2:material in the fog")).toBe(
232
+ "the markings from reference image A over "
233
+ + "the material from reference image B in the fog",
234
+ )
235
+ })
236
+
237
+ it("leaves a doubled space the author put in their own prose alone (seam-scoped)", () => {
238
+ expect(buildEntities("@nessie:1 looms slowly over @chair:2 in the fog")).toBe(
239
+ "the creature from reference image A looms slowly over "
240
+ + "the object from reference image B in the fog",
241
+ )
242
+ })
243
+
244
+ it("preserves newlines — the collapse class is horizontal-only", () => {
245
+ expect(buildEntities("@nessie:1 looms\n\n@chair:2 in the fog")).toBe(
246
+ "the creature from reference image A looms\n\n"
247
+ + "the object from reference image B in the fog",
248
+ )
249
+ })
250
+
251
+ it("an entity mention that opens an indented line keeps the indent verbatim", () => {
252
+ expect(buildEntities("Scene:\n @nessie:1 looms over @chair:2 in the fog")).toBe(
253
+ "Scene:\n the creature from reference image A looms over "
254
+ + "the object from reference image B in the fog",
255
+ )
256
+ })
257
+ })
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `readSubjectFields` is the CANVAS door into the subject fold — the twin of
3
+ * the route's `subjectSchema`. It reads untrusted persisted node JSONB (a
4
+ * workflow write is validated only as `z.record(z.string(), z.unknown())`), so
5
+ * every assertion here is on the three contracts it shares with
6
+ * `readDirectionFields`: drop-never-throw, `undefined` never `{}`, and bounds
7
+ * that MATCH the wire door by shared constant.
8
+ */
9
+ import { describe, it, expect, beforeEach } from "vitest"
10
+ import { readSubjectFields } from "../read-node-direction.js"
11
+ import {
12
+ SUBJECT_ARRAY_CEILING,
13
+ SUBJECT_ID_MAX_CHARS,
14
+ SUBJECT_KEYS,
15
+ getRegisteredSubjectKeys,
16
+ renderSubjectHints,
17
+ } from "../subject-registry.js"
18
+ import { registerPersonPack, resetPersonPacks } from "../person-packs.js"
19
+ import { resetCatalogPacks } from "../catalog-packs.js"
20
+ import { getPersonPromptHint } from "../person.js"
21
+
22
+ beforeEach(() => {
23
+ resetPersonPacks()
24
+ resetCatalogPacks()
25
+ })
26
+
27
+ const pack = {
28
+ id: "test/person-sector",
29
+ dimensions: [{ dimension: "sector-attire", field: "sectorAttire", label: "Sector Attire" }],
30
+ entries: [
31
+ {
32
+ id: "attire-modest-suit",
33
+ label: "Modest Suit",
34
+ group: "Attire",
35
+ dimension: "sector-attire",
36
+ description: "a modest tailored suit",
37
+ promptHint: "wearing a modest tailored suit",
38
+ },
39
+ ],
40
+ }
41
+
42
+ describe("readSubjectFields — shape", () => {
43
+ it("round-trips the ids a node actually stores", () => {
44
+ expect(
45
+ readSubjectFields({
46
+ type: "woman",
47
+ hairBase: "base-buzz",
48
+ jewelry: ["jewelry-gold", "jewelry-silver"],
49
+ animal: "dog-corgi",
50
+ }),
51
+ ).toEqual({
52
+ type: "woman",
53
+ hairBase: "base-buzz",
54
+ jewelry: ["jewelry-gold", "jewelry-silver"],
55
+ animal: "dog-corgi",
56
+ })
57
+ })
58
+
59
+ it("returns undefined — never {} — for a blob with nothing readable", () => {
60
+ expect(readSubjectFields(undefined)).toBeUndefined()
61
+ expect(readSubjectFields(null)).toBeUndefined()
62
+ expect(readSubjectFields("nope")).toBeUndefined()
63
+ expect(readSubjectFields([])).toBeUndefined()
64
+ expect(readSubjectFields({})).toBeUndefined()
65
+ expect(readSubjectFields({ notAField: "man" })).toBeUndefined()
66
+ })
67
+
68
+ it("drops junk instead of throwing on it", () => {
69
+ expect(
70
+ readSubjectFields({
71
+ type: { nested: true },
72
+ hairBase: 42,
73
+ jewelry: [null, { x: 1 }, "jewelry-gold"],
74
+ makeup: "",
75
+ }),
76
+ ).toEqual({ jewelry: ["jewelry-gold"] })
77
+ })
78
+
79
+ it("filters junk BEFORE the ceiling, so valid ids behind it survive", () => {
80
+ const v = [...Array.from({ length: SUBJECT_ARRAY_CEILING }, () => 1), "jewelry-gold"]
81
+ expect(readSubjectFields({ jewelry: v })).toEqual({ jewelry: ["jewelry-gold"] })
82
+ })
83
+ })
84
+
85
+ describe("readSubjectFields — bounds shared with the wire door", () => {
86
+ it("keeps the first SUBJECT_ARRAY_CEILING entries of an over-long array", () => {
87
+ const many = Array.from({ length: SUBJECT_ARRAY_CEILING + 5 }, (_, i) => `id-${i}`)
88
+ const out = readSubjectFields({ jewelry: many }) as Record<string, unknown>
89
+ expect(out.jewelry).toEqual(many.slice(0, SUBJECT_ARRAY_CEILING))
90
+ })
91
+
92
+ it("drops an id longer than SUBJECT_ID_MAX_CHARS, keeping one exactly at the bound", () => {
93
+ const ok = "x".repeat(SUBJECT_ID_MAX_CHARS)
94
+ const tooLong = "x".repeat(SUBJECT_ID_MAX_CHARS + 1)
95
+ expect(readSubjectFields({ type: tooLong })).toBeUndefined()
96
+ expect(readSubjectFields({ type: ok })).toEqual({ type: ok })
97
+ expect(readSubjectFields({ jewelry: [tooLong, ok] })).toEqual({ jewelry: [ok] })
98
+ })
99
+
100
+ it("does NOT apply the per-dimension cap — that stays the renderer's slice", () => {
101
+ const four = ["jewelry-subtle", "jewelry-statement", "jewelry-gold", "jewelry-silver"]
102
+ expect((readSubjectFields({ jewelry: four }) as Record<string, unknown>).jewelry).toEqual(four)
103
+ })
104
+ })
105
+
106
+ describe("readSubjectFields — customAge, the one number", () => {
107
+ it("passes a finite number through for the renderer to clamp", () => {
108
+ expect(readSubjectFields({ age: "age-custom", customAge: 34 })).toEqual({
109
+ age: "age-custom",
110
+ customAge: 34,
111
+ })
112
+ expect(readSubjectFields({ customAge: 9999 })).toEqual({ customAge: 9999 })
113
+ expect(renderSubjectHints(readSubjectFields({ age: "age-custom", customAge: 9999 }), {
114
+ surface: "image",
115
+ })).toEqual(["120 years old"])
116
+ })
117
+
118
+ it("drops a non-finite or non-number customAge", () => {
119
+ expect(readSubjectFields({ customAge: Number.NaN })).toBeUndefined()
120
+ expect(readSubjectFields({ customAge: "34" })).toBeUndefined()
121
+ })
122
+ })
123
+
124
+ describe("readSubjectFields — pack awareness", () => {
125
+ it("with no packs registered, the accepted key set IS SUBJECT_KEYS", () => {
126
+ expect(getRegisteredSubjectKeys()).toEqual([...SUBJECT_KEYS])
127
+ })
128
+
129
+ it("reads a deployment-registered pack dimension, and folds its hint", () => {
130
+ expect(readSubjectFields({ sectorAttire: "attire-modest-suit" })).toBeUndefined()
131
+ registerPersonPack(pack)
132
+ expect(getRegisteredSubjectKeys()).toContain("sectorAttire")
133
+ expect(readSubjectFields({ sectorAttire: "attire-modest-suit" })).toEqual({
134
+ sectorAttire: "attire-modest-suit",
135
+ })
136
+ expect(
137
+ renderSubjectHints({ type: "woman", sectorAttire: "attire-modest-suit" }, { surface: "image" }),
138
+ ).toEqual([`${getPersonPromptHint("woman")}, wearing a modest tailored suit`])
139
+ })
140
+ })
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The `subject` fold where it actually lands: `assembleImageInput` (stills) and
3
+ * `composeVideoPromptText` (clips).
4
+ *
5
+ * THE LOAD-BEARING TEST IN THIS FILE is the NO-SUBJECT ORACLE: with no
6
+ * `subject` — which is every caller in the world on the day this ships — the
7
+ * assembled prompt must be byte-for-byte what it was before the channel
8
+ * existed. That is what makes the rollout dark. The oracle is written as an
9
+ * independent recomputation (`joinPromptHints` over `renderDirectionHints`),
10
+ * not as a snapshot, so it keeps testing the claim if the catalogs change.
11
+ */
12
+ import { describe, it, expect } from "vitest"
13
+ import { assembleImageInput } from "../assemble-image-input.js"
14
+ import { composeVideoPromptText } from "../assemble-video-input.js"
15
+ import { buildImagePrompt } from "../prompt-builder.js"
16
+ import { joinPromptHints, PROMPT_HINT_SEPARATOR } from "../prompt-hint-join.js"
17
+ import { renderDirectionHints, IMAGE_HINT_MODE_DEFAULT, VIDEO_HINT_MODE_DEFAULT } from "../direction-registry.js"
18
+ import {
19
+ renderSubjectHints,
20
+ SUBJECT_IMAGE_HINT_MODE_DEFAULT,
21
+ SUBJECT_VIDEO_HINT_MODE_DEFAULT,
22
+ } from "../subject-registry.js"
23
+ import { getMaxImagePromptChars } from "@nodaro/shared"
24
+ import type { ConnectedReference } from "@nodaro/shared"
25
+
26
+ const DIRECTION = {
27
+ shotSize: "wide-shot",
28
+ timeOfDay: "golden-hour",
29
+ lens: "wide-24mm",
30
+ style: "anime",
31
+ } as const
32
+
33
+ const SUBJECT = {
34
+ type: "woman",
35
+ ethnicity: "east-asian",
36
+ hairBase: "base-short-straight",
37
+ makeup: "makeup-smoky",
38
+ animal: "dog-corgi",
39
+ } as const
40
+
41
+ const STRUCTURED = { person: { age: 34, hair: "auburn" } } as const
42
+
43
+ const KIRA: ConnectedReference = {
44
+ id: "kira-id",
45
+ defaultName: "Kira",
46
+ source: "wired-character",
47
+ url: "https://r2.example/kira.png",
48
+ characterSlug: "kira",
49
+ variantSlug: undefined,
50
+ characterCanonicalDescription: "a young woman with copper hair",
51
+ variantDescription: null,
52
+ variantDisplayName: "canonical",
53
+ }
54
+
55
+ describe("the no-subject oracle — the fold is dark until a caller opts in", () => {
56
+ const cases: ReadonlyArray<{ name: string; input: Parameters<typeof assembleImageInput>[0] }> = [
57
+ {
58
+ name: "bare prompt (the exact no-op contract, untrimmed)",
59
+ input: { userPrompt: " a knight on a hill ", provider: "nano-banana" },
60
+ },
61
+ {
62
+ name: "direction only",
63
+ input: { userPrompt: "a knight on a hill", provider: "nano-banana", direction: DIRECTION },
64
+ },
65
+ {
66
+ name: "structured only",
67
+ input: { userPrompt: "a knight on a hill", provider: "nano-banana", structured: STRUCTURED },
68
+ },
69
+ {
70
+ name: "direction + structured + a bound reference",
71
+ input: {
72
+ userPrompt: "@kira:1 on a hill",
73
+ provider: "nano-banana",
74
+ connectedReferences: [KIRA],
75
+ direction: DIRECTION,
76
+ structured: STRUCTURED,
77
+ },
78
+ },
79
+ ]
80
+
81
+ for (const { name, input } of cases) {
82
+ it(`is unchanged by the channel's existence — ${name}`, () => {
83
+ const withNothing = assembleImageInput(input)
84
+ // An explicitly-undefined subject, an empty bag, and a bag of keys the
85
+ // platform does not know are all the same as not passing one.
86
+ expect(assembleImageInput({ ...input, subject: undefined })).toEqual(withNothing)
87
+ expect(assembleImageInput({ ...input, subject: {} })).toEqual(withNothing)
88
+ expect(assembleImageInput({ ...input, subject: { notAField: "x" } })).toEqual(withNothing)
89
+ })
90
+ }
91
+
92
+ it("returns the user's prompt VERBATIM AND UNTRIMMED with no lever at all", () => {
93
+ const userPrompt = " a knight on a hill "
94
+ expect(assembleImageInput({ userPrompt, provider: "nano-banana" }).prompt).toBe(userPrompt)
95
+ })
96
+
97
+ it("matches an independent recomputation of the direction-only fold", () => {
98
+ const userPrompt = "a knight on a hill"
99
+ const expected = buildImagePrompt({
100
+ prompt: joinPromptHints(
101
+ userPrompt,
102
+ renderDirectionHints(DIRECTION, { surface: "image", mode: IMAGE_HINT_MODE_DEFAULT }),
103
+ ),
104
+ provider: "nano-banana",
105
+ })
106
+ expect(assembleImageInput({ userPrompt, provider: "nano-banana", direction: DIRECTION })).toEqual(
107
+ expected,
108
+ )
109
+ })
110
+
111
+ it("leaves the video composer's no-op contract intact (undefined stays undefined)", () => {
112
+ expect(composeVideoPromptText(undefined, undefined)).toBeUndefined()
113
+ expect(composeVideoPromptText(undefined, undefined, undefined, { subject: {} })).toBeUndefined()
114
+ expect(
115
+ composeVideoPromptText(undefined, undefined, undefined, { subject: { notAField: "x" } }),
116
+ ).toBeUndefined()
117
+ expect(composeVideoPromptText(" a clip ", undefined, undefined, { subject: {} })).toBe(
118
+ " a clip ",
119
+ )
120
+ })
121
+ })
122
+
123
+ describe("the subject fold — position and content", () => {
124
+ it("lands the subject clauses AHEAD of the direction clauses", () => {
125
+ const userPrompt = "on the seawall"
126
+ const subjectHints = renderSubjectHints(SUBJECT, {
127
+ surface: "image",
128
+ mode: SUBJECT_IMAGE_HINT_MODE_DEFAULT,
129
+ })
130
+ const directionHints = renderDirectionHints(DIRECTION, {
131
+ surface: "image",
132
+ mode: IMAGE_HINT_MODE_DEFAULT,
133
+ })
134
+ const result = assembleImageInput({
135
+ userPrompt,
136
+ provider: "nano-banana",
137
+ subject: SUBJECT,
138
+ direction: DIRECTION,
139
+ })
140
+ expect(result.prompt).toBe(
141
+ buildImagePrompt({
142
+ prompt: joinPromptHints(userPrompt, [...subjectHints, ...directionHints]),
143
+ provider: "nano-banana",
144
+ }).prompt,
145
+ )
146
+ expect(result.prompt.indexOf(subjectHints[0]!)).toBeLessThan(
147
+ result.prompt.indexOf(directionHints[0]!),
148
+ )
149
+ })
150
+
151
+ it("joins person and styling as ONE clause each, not N sentence fragments", () => {
152
+ const result = assembleImageInput({
153
+ userPrompt: "on the seawall",
154
+ provider: "nano-banana",
155
+ subject: { type: "woman", ethnicity: "east-asian", makeup: "makeup-smoky" },
156
+ })
157
+ // Three catalog fragments, but only ONE sentence separator from the person
158
+ // clause: the person row comma-joins its own fragments.
159
+ const sentences = result.prompt.split(PROMPT_HINT_SEPARATOR)
160
+ expect(sentences).toHaveLength(3) // prose. person clause. styling clause.
161
+ expect(sentences[1]).toContain(", ")
162
+ })
163
+
164
+ it("folds the subject on the video surface, compact, ahead of direction", () => {
165
+ const composed = composeVideoPromptText("she walks", DIRECTION, undefined, { subject: SUBJECT })
166
+ expect(composed).toBe(
167
+ joinPromptHints("she walks", [
168
+ ...renderSubjectHints(SUBJECT, {
169
+ surface: "video",
170
+ mode: SUBJECT_VIDEO_HINT_MODE_DEFAULT,
171
+ }),
172
+ ...renderDirectionHints(DIRECTION, { surface: "video", mode: VIDEO_HINT_MODE_DEFAULT }),
173
+ ]),
174
+ )
175
+ })
176
+
177
+ it("honors an explicit subject verbosity override", () => {
178
+ const full = composeVideoPromptText("she walks", undefined, undefined, {
179
+ subject: SUBJECT,
180
+ subjectHintMode: "full",
181
+ })
182
+ const compact = composeVideoPromptText("she walks", undefined, undefined, { subject: SUBJECT })
183
+ expect(full).not.toBe(compact)
184
+ expect(full!.length).toBeGreaterThan(compact!.length)
185
+ })
186
+ })
187
+
188
+ describe("the subject fold — under the provider cap", () => {
189
+ const SEEDREAM_CAP = getMaxImagePromptChars("seedream")
190
+ // A broad direction fold plus a broad subject fold, on prose long enough that
191
+ // the three together clear the lowest cap in the catalog.
192
+ const BIG_DIRECTION = {
193
+ ...DIRECTION,
194
+ angle: "low-angle",
195
+ cameraFormat: "16mm-film",
196
+ isoValue: "iso-100",
197
+ lightingStyle: "rembrandt",
198
+ colorLook: "teal-orange",
199
+ atmosphere: ["fog"],
200
+ mood: ["happy", "joyful"],
201
+ photographer: ["annie-leibovitz"],
202
+ setting: "forest",
203
+ } as const
204
+ const PROSE = "She walks the seawall at dusk. " + "The waves are loud. ".repeat(90)
205
+
206
+ it("sheds the direction fold BEFORE the subject fold", () => {
207
+ const subjectHints = renderSubjectHints(SUBJECT, {
208
+ surface: "image",
209
+ mode: SUBJECT_IMAGE_HINT_MODE_DEFAULT,
210
+ })
211
+ const directionHints = renderDirectionHints(BIG_DIRECTION, {
212
+ surface: "image",
213
+ mode: IMAGE_HINT_MODE_DEFAULT,
214
+ })
215
+ // Non-vacuity: the un-shed fold really does overflow.
216
+ expect(
217
+ joinPromptHints(PROSE, [...subjectHints, ...directionHints]).length,
218
+ ).toBeGreaterThan(SEEDREAM_CAP)
219
+
220
+ const result = assembleImageInput({
221
+ userPrompt: PROSE,
222
+ provider: "seedream",
223
+ subject: SUBJECT,
224
+ direction: BIG_DIRECTION,
225
+ })
226
+ expect(result.prompt.length).toBeLessThanOrEqual(SEEDREAM_CAP)
227
+ expect(result.prompt.endsWith("...")).toBe(false)
228
+ // Who is in the shot outlived how it was lit.
229
+ expect(result.prompt).toContain(subjectHints[0])
230
+ expect(result.prompt).not.toContain(directionHints[directionHints.length - 1])
231
+ })
232
+ })