@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
@@ -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,179 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { applyVideoNegativePrompt, getMaxImagePromptChars } from "@nodaro/shared"
3
+ import type { CharacterDef, ConnectedReference } from "@nodaro/shared"
4
+ import { buildImagePrompt } from "../prompt-builder.js"
5
+ import { assembleImageInput } from "../assemble-image-input.js"
6
+ import { composeVideoPromptText } from "../assemble-video-input.js"
7
+ import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
8
+ import { getStylePromptHint } from "../style.js"
9
+
10
+ /**
11
+ * WHERE THE `[style]` SECTION ENDS. The section has no terminator and every
12
+ * assembler downstream of the composer appends to the string it returns — the
13
+ * reference resolvers' role phrases and element directives, the legacy
14
+ * character descriptions, `Style:` and `Avoid:`. Line-joined onto a prompt that
15
+ * ENDS with the section, each of them reads as one more look clause under the
16
+ * header, which is exactly the confusion the section exists to remove.
17
+ *
18
+ * Two shapes keep them out, one per kind of text:
19
+ * - BODY content — a reference binding, an element directive, a character
20
+ * description — is SPLICED in ahead of the section, where the prose it
21
+ * belongs with already is. The look tail stays last, which is where it was
22
+ * measured to be free.
23
+ * - The self-labeling control lines (`Style:` / `Avoid:`) stay at the end and
24
+ * close the header's scope with a blank line instead.
25
+ *
26
+ * Assertions read the header as a LITERAL and the clause wording through the
27
+ * catalogs, so this suite pins the BOUNDARY, not the catalog copy.
28
+ */
29
+
30
+ /** The `\n\n`-delimited block the `[style]:` header opens — every line the
31
+ * model reads as a look clause. `""` when the prompt carries no section. */
32
+ const styleBlockOf = (prompt: string): string =>
33
+ prompt.split("\n\n").find((b) => b.startsWith("[style]:")) ?? ""
34
+
35
+ const LIBRARY: ConnectedReference = {
36
+ id: "l", defaultName: "Old Library", source: "wired-location",
37
+ url: "https://cdn/library.png", locationSlug: "old-library",
38
+ }
39
+
40
+ const KIRA: ConnectedReference = {
41
+ id: "kira", defaultName: "Kira", source: "wired-character",
42
+ url: "https://cdn/kira.png", characterSlug: "kira",
43
+ characterCanonicalDescription: "a young woman with copper hair",
44
+ variantSlug: undefined, variantDescription: null, variantDisplayName: "canonical",
45
+ }
46
+
47
+ const KIRA_DEF: CharacterDef = {
48
+ id: "kira", name: "Kira", type: "description", category: "character",
49
+ description: "auburn hair, hazel eyes",
50
+ }
51
+
52
+ /** Look on both lines: `style` is a film clause, `shotSize` a scene clause. */
53
+ const DIRECTION = { style: "anime", shotSize: "wide-shot" } as const
54
+
55
+ /** What the inline `style` text renders as in the `Style:` control line. */
56
+ const CINEMATIC = getStylePromptHint("cinematic")
57
+
58
+ /** The trailing role phrase a canonical (unmentioned) wired location renders. */
59
+ const LOCATION_PHRASE = "the location from reference image A"
60
+ /** The video twin, bound to the resolver's `@image_N` shape. */
61
+ const CHARACTER_PHRASE = "the person from @image_1"
62
+
63
+ describe("image assembly — nothing lands under the `[style]` header", () => {
64
+ const hybrid = assembleImageInput({
65
+ userPrompt: "a man walks",
66
+ provider: "nano-banana",
67
+ direction: DIRECTION,
68
+ connectedReferences: [LIBRARY],
69
+ referenceFormat: "hybrid",
70
+ style: "cinematic",
71
+ negativePrompt: "blurry",
72
+ }).prompt
73
+
74
+ it("splices the reference binding into the body, ahead of the section", () => {
75
+ // Non-vacuity: the section really is there, carrying both look lines.
76
+ expect(styleBlockOf(hybrid)).toContain("[style]:\n")
77
+ expect(styleBlockOf(hybrid)).toContain("wide shot")
78
+
79
+ expect(styleBlockOf(hybrid)).not.toContain(LOCATION_PHRASE)
80
+ expect(hybrid.indexOf(LOCATION_PHRASE)).toBeGreaterThan(-1)
81
+ expect(hybrid.indexOf(LOCATION_PHRASE)).toBeLessThan(hybrid.indexOf("[style]:"))
82
+ })
83
+
84
+ it("closes the section with a blank line before `Style:` / `Avoid:`", () => {
85
+ expect(styleBlockOf(hybrid)).not.toContain("Style:")
86
+ expect(styleBlockOf(hybrid)).not.toContain("Avoid:")
87
+ // The control lines stay together at the very end, one block of their own:
88
+ // the blank line is `Style:`'s to add, and `Avoid:` sees a closed section.
89
+ expect(hybrid.endsWith(`\n\nStyle: ${CINEMATIC}\nAvoid: blurry`)).toBe(true)
90
+ })
91
+
92
+ it("splices the LEGACY character description into the body too", () => {
93
+ const legacy = assembleImageInput({
94
+ userPrompt: "a man walks",
95
+ provider: "nano-banana",
96
+ direction: DIRECTION,
97
+ characterDefs: [KIRA_DEF],
98
+ }).prompt
99
+ const desc = "Include character 'Kira': auburn hair, hazel eyes."
100
+ expect(legacy).toContain(desc)
101
+ expect(styleBlockOf(legacy)).not.toContain(desc)
102
+ expect(legacy.indexOf(desc)).toBeLessThan(legacy.indexOf("[style]:"))
103
+ })
104
+
105
+ it("honors a user-overridden wrapper template while keeping the section last", () => {
106
+ const legacy = assembleImageInput({
107
+ userPrompt: "a man walks",
108
+ provider: "nano-banana",
109
+ direction: DIRECTION,
110
+ characterDefs: [KIRA_DEF],
111
+ userTemplates: { "generate-image-wrapper": "{assetDescriptions} // {userPrompt}" },
112
+ }).prompt
113
+ expect(legacy.startsWith("Include character 'Kira': auburn hair, hazel eyes. // a man walks")).toBe(true)
114
+ expect(styleBlockOf(legacy)).not.toContain("Kira")
115
+ })
116
+
117
+ it("re-derives the separator after the cap's tail cut, within the reservation", () => {
118
+ // The control lines are reserved BEFORE the cut and rendered AFTER it, and
119
+ // the cut moves the boundary they read. Both directions, one cap:
120
+ const cap = getMaxImagePromptChars("seedream")
121
+ const cut = (prompt: string) =>
122
+ buildImagePrompt({ prompt, provider: "seedream", style: "cinematic", negativePrompt: "blurry" }).prompt
123
+ const tail = `\nStyle: ${CINEMATIC}\nAvoid: blurry`
124
+
125
+ // (a) a short section, cut away entirely → the separator narrows to `\n`.
126
+ const shortSection = cut(`${"a man walks. ".repeat(400)}\n\n[style]:\nanime style`)
127
+ expect(shortSection.length).toBeLessThanOrEqual(cap)
128
+ expect(shortSection.endsWith(`...${tail}`)).toBe(true)
129
+
130
+ // (b) a section longer than the reservation, so the cut lands INSIDE it →
131
+ // the separator stays a blank line, exactly what was reserved.
132
+ const look = "anime style, ".repeat(24)
133
+ const longSection = cut(`${"x".repeat(cap - look.length - 60)}\n\n[style]:\n${look}`)
134
+ expect(longSection.length).toBeLessThanOrEqual(cap)
135
+ expect(longSection.endsWith(`...\n${tail}`)).toBe(true)
136
+ })
137
+
138
+ it("keeps the no-section shapes byte-identical (the append fallback)", () => {
139
+ const noSection = assembleImageInput({
140
+ userPrompt: "a man walks",
141
+ provider: "nano-banana",
142
+ connectedReferences: [LIBRARY],
143
+ referenceFormat: "hybrid",
144
+ style: "cinematic",
145
+ negativePrompt: "blurry",
146
+ }).prompt
147
+ expect(noSection).toBe(
148
+ `A man walks\n${LOCATION_PHRASE}\nStyle: ${CINEMATIC}\nAvoid: blurry`,
149
+ )
150
+ })
151
+ })
152
+
153
+ describe("video assembly — nothing lands under the `[style]` header", () => {
154
+ const body = composeVideoPromptText("a knight rides", DIRECTION)
155
+ const framed = resolveVideoReferenceCore({
156
+ prompt: body,
157
+ wiredCharRefs: [KIRA],
158
+ hybridRoles: true,
159
+ }).prompt!
160
+
161
+ it("splices the resolver's role phrase ahead of the section, which ends the prompt", () => {
162
+ expect(styleBlockOf(framed)).toContain("[style]:\n")
163
+ expect(styleBlockOf(framed)).not.toContain(CHARACTER_PHRASE)
164
+ expect(framed.indexOf(CHARACTER_PHRASE)).toBeLessThan(framed.indexOf("[style]:"))
165
+ // The look tail really is the tail: nothing follows the section.
166
+ expect(framed.endsWith(styleBlockOf(framed))).toBe(true)
167
+ })
168
+
169
+ it("closes the section with a blank line before the folded `Avoid:`", () => {
170
+ const withNeg = applyVideoNegativePrompt(framed, "blurry, watermark", "seedance-2").prompt!
171
+ expect(styleBlockOf(withNeg)).not.toContain("Avoid:")
172
+ expect(withNeg.endsWith("\n\nAvoid: blurry, watermark")).toBe(true)
173
+ })
174
+
175
+ it("keeps the sectionless `Avoid:` join byte-identical", () => {
176
+ const plain = applyVideoNegativePrompt("a knight rides", "blurry", "seedance-2").prompt
177
+ expect(plain).toBe("a knight rides\nAvoid: blurry")
178
+ })
179
+ })
@@ -0,0 +1,251 @@
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 { renderStyleSection } from "../prompt-style-section.js"
19
+ import {
20
+ renderSubjectHints,
21
+ SUBJECT_IMAGE_HINT_MODE_DEFAULT,
22
+ SUBJECT_VIDEO_HINT_MODE_DEFAULT,
23
+ } from "../subject-registry.js"
24
+ import { getMaxImagePromptChars } from "@nodaro/shared"
25
+ import type { ConnectedReference } from "@nodaro/shared"
26
+
27
+ const DIRECTION = {
28
+ shotSize: "wide-shot",
29
+ timeOfDay: "golden-hour",
30
+ lens: "wide-24mm",
31
+ style: "anime",
32
+ } as const
33
+
34
+ const SUBJECT = {
35
+ type: "woman",
36
+ ethnicity: "east-asian",
37
+ hairBase: "base-short-straight",
38
+ makeup: "makeup-smoky",
39
+ animal: "dog-corgi",
40
+ } as const
41
+
42
+ const STRUCTURED = { person: { age: 34, hair: "auburn" } } as const
43
+
44
+ const KIRA: ConnectedReference = {
45
+ id: "kira-id",
46
+ defaultName: "Kira",
47
+ source: "wired-character",
48
+ url: "https://r2.example/kira.png",
49
+ characterSlug: "kira",
50
+ variantSlug: undefined,
51
+ characterCanonicalDescription: "a young woman with copper hair",
52
+ variantDescription: null,
53
+ variantDisplayName: "canonical",
54
+ }
55
+
56
+ describe("the no-subject oracle — the fold is dark until a caller opts in", () => {
57
+ const cases: ReadonlyArray<{ name: string; input: Parameters<typeof assembleImageInput>[0] }> = [
58
+ {
59
+ name: "bare prompt (the exact no-op contract, untrimmed)",
60
+ input: { userPrompt: " a knight on a hill ", provider: "nano-banana" },
61
+ },
62
+ {
63
+ name: "direction only",
64
+ input: { userPrompt: "a knight on a hill", provider: "nano-banana", direction: DIRECTION },
65
+ },
66
+ {
67
+ name: "structured only",
68
+ input: { userPrompt: "a knight on a hill", provider: "nano-banana", structured: STRUCTURED },
69
+ },
70
+ {
71
+ name: "direction + structured + a bound reference",
72
+ input: {
73
+ userPrompt: "@kira:1 on a hill",
74
+ provider: "nano-banana",
75
+ connectedReferences: [KIRA],
76
+ direction: DIRECTION,
77
+ structured: STRUCTURED,
78
+ },
79
+ },
80
+ ]
81
+
82
+ for (const { name, input } of cases) {
83
+ it(`is unchanged by the channel's existence — ${name}`, () => {
84
+ const withNothing = assembleImageInput(input)
85
+ // An explicitly-undefined subject, an empty bag, and a bag of keys the
86
+ // platform does not know are all the same as not passing one.
87
+ expect(assembleImageInput({ ...input, subject: undefined })).toEqual(withNothing)
88
+ expect(assembleImageInput({ ...input, subject: {} })).toEqual(withNothing)
89
+ expect(assembleImageInput({ ...input, subject: { notAField: "x" } })).toEqual(withNothing)
90
+ })
91
+ }
92
+
93
+ it("returns the user's prompt VERBATIM AND UNTRIMMED with no lever at all", () => {
94
+ const userPrompt = " a knight on a hill "
95
+ expect(assembleImageInput({ userPrompt, provider: "nano-banana" }).prompt).toBe(userPrompt)
96
+ })
97
+
98
+ it("matches an independent recomputation of the direction-only fold", () => {
99
+ // Every image-surface direction row is `look`, so a direction-only fold
100
+ // adds NOTHING to the body: the prompt is trimmed (something folded) and
101
+ // the whole fold reads in the section.
102
+ const userPrompt = "a knight on a hill"
103
+ const expected = buildImagePrompt({
104
+ prompt: `${userPrompt}\n\n${renderStyleSection(DIRECTION, {
105
+ surface: "image",
106
+ mode: IMAGE_HINT_MODE_DEFAULT,
107
+ })}`,
108
+ provider: "nano-banana",
109
+ })
110
+ expect(assembleImageInput({ userPrompt, provider: "nano-banana", direction: DIRECTION })).toEqual(
111
+ expected,
112
+ )
113
+ })
114
+
115
+ it("leaves the video composer's no-op contract intact (undefined stays undefined)", () => {
116
+ expect(composeVideoPromptText(undefined, undefined)).toBeUndefined()
117
+ expect(composeVideoPromptText(undefined, undefined, undefined, { subject: {} })).toBeUndefined()
118
+ expect(
119
+ composeVideoPromptText(undefined, undefined, undefined, { subject: { notAField: "x" } }),
120
+ ).toBeUndefined()
121
+ expect(composeVideoPromptText(" a clip ", undefined, undefined, { subject: {} })).toBe(
122
+ " a clip ",
123
+ )
124
+ })
125
+ })
126
+
127
+ describe("the subject fold — position and content", () => {
128
+ it("lands the subject clauses in the BODY, ahead of the direction section", () => {
129
+ const userPrompt = "on the seawall"
130
+ const subjectHints = renderSubjectHints(SUBJECT, {
131
+ surface: "image",
132
+ mode: SUBJECT_IMAGE_HINT_MODE_DEFAULT,
133
+ })
134
+ const directionHints = renderDirectionHints(DIRECTION, {
135
+ surface: "image",
136
+ mode: IMAGE_HINT_MODE_DEFAULT,
137
+ })
138
+ const result = assembleImageInput({
139
+ userPrompt,
140
+ provider: "nano-banana",
141
+ subject: SUBJECT,
142
+ direction: DIRECTION,
143
+ })
144
+ // The subject is the noun phrase the look modifies, so it stays inline with
145
+ // the prose; the direction fold lifts out behind it.
146
+ expect(result.prompt).toBe(
147
+ buildImagePrompt({
148
+ prompt: `${joinPromptHints(userPrompt, subjectHints)}\n\n${renderStyleSection(DIRECTION, {
149
+ surface: "image",
150
+ mode: IMAGE_HINT_MODE_DEFAULT,
151
+ })}`,
152
+ provider: "nano-banana",
153
+ }).prompt,
154
+ )
155
+ expect(result.prompt.indexOf(subjectHints[0]!)).toBeLessThan(
156
+ result.prompt.indexOf(directionHints[0]!),
157
+ )
158
+ })
159
+
160
+ it("joins person and styling as ONE clause each, not N sentence fragments", () => {
161
+ const result = assembleImageInput({
162
+ userPrompt: "on the seawall",
163
+ provider: "nano-banana",
164
+ subject: { type: "woman", ethnicity: "east-asian", makeup: "makeup-smoky" },
165
+ })
166
+ // Three catalog fragments, but only ONE sentence separator from the person
167
+ // clause: the person row comma-joins its own fragments.
168
+ const sentences = result.prompt.split(PROMPT_HINT_SEPARATOR)
169
+ expect(sentences).toHaveLength(3) // prose. person clause. styling clause.
170
+ expect(sentences[1]).toContain(", ")
171
+ })
172
+
173
+ it("folds the subject on the video surface, compact, in the body", () => {
174
+ const composed = composeVideoPromptText("she walks", DIRECTION, undefined, { subject: SUBJECT })
175
+ expect(composed).toBe(
176
+ `${joinPromptHints(
177
+ "she walks",
178
+ renderSubjectHints(SUBJECT, {
179
+ surface: "video",
180
+ mode: SUBJECT_VIDEO_HINT_MODE_DEFAULT,
181
+ }),
182
+ )}\n\n${renderStyleSection(DIRECTION, {
183
+ surface: "video",
184
+ mode: VIDEO_HINT_MODE_DEFAULT,
185
+ })}`,
186
+ )
187
+ })
188
+
189
+ it("honors an explicit subject verbosity override", () => {
190
+ const full = composeVideoPromptText("she walks", undefined, undefined, {
191
+ subject: SUBJECT,
192
+ subjectHintMode: "full",
193
+ })
194
+ const compact = composeVideoPromptText("she walks", undefined, undefined, { subject: SUBJECT })
195
+ expect(full).not.toBe(compact)
196
+ expect(full!.length).toBeGreaterThan(compact!.length)
197
+ })
198
+ })
199
+
200
+ describe("the subject fold — under the provider cap", () => {
201
+ const SEEDREAM_CAP = getMaxImagePromptChars("seedream")
202
+ // A broad direction fold plus a broad subject fold, on prose long enough that
203
+ // the three together clear the lowest cap in the catalog.
204
+ const BIG_DIRECTION = {
205
+ ...DIRECTION,
206
+ angle: "low-angle",
207
+ cameraFormat: "16mm-film",
208
+ isoValue: "iso-100",
209
+ lightingStyle: "rembrandt",
210
+ colorLook: "teal-orange",
211
+ atmosphere: ["fog"],
212
+ mood: ["happy", "joyful"],
213
+ photographer: ["annie-leibovitz"],
214
+ setting: "forest",
215
+ } as const
216
+ const PROSE = "She walks the seawall at dusk. " + "The waves are loud. ".repeat(90)
217
+
218
+ it("sheds the direction fold BEFORE the subject fold", () => {
219
+ const subjectHints = renderSubjectHints(SUBJECT, {
220
+ surface: "image",
221
+ mode: SUBJECT_IMAGE_HINT_MODE_DEFAULT,
222
+ })
223
+ const directionHints = renderDirectionHints(BIG_DIRECTION, {
224
+ surface: "image",
225
+ mode: IMAGE_HINT_MODE_DEFAULT,
226
+ })
227
+ // Non-vacuity: the un-shed fold really does overflow. Measured through a
228
+ // high-cap provider so the oracle is the assembler's own composition, not a
229
+ // hand-rebuilt approximation of it.
230
+ expect(
231
+ assembleImageInput({
232
+ userPrompt: PROSE,
233
+ provider: "nano-banana-pro",
234
+ subject: SUBJECT,
235
+ direction: BIG_DIRECTION,
236
+ }).prompt.length,
237
+ ).toBeGreaterThan(SEEDREAM_CAP)
238
+
239
+ const result = assembleImageInput({
240
+ userPrompt: PROSE,
241
+ provider: "seedream",
242
+ subject: SUBJECT,
243
+ direction: BIG_DIRECTION,
244
+ })
245
+ expect(result.prompt.length).toBeLessThanOrEqual(SEEDREAM_CAP)
246
+ expect(result.prompt.endsWith("...")).toBe(false)
247
+ // Who is in the shot outlived how it was lit.
248
+ expect(result.prompt).toContain(subjectHints[0])
249
+ expect(result.prompt).not.toContain(directionHints[directionHints.length - 1])
250
+ })
251
+ })