@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.
- package/dist/index.cjs +627 -177
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +726 -33
- package/dist/index.d.ts +726 -33
- package/dist/index.js +598 -179
- 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 +236 -0
- package/src/__tests__/assemble-image-input.test.ts +100 -19
- package/src/__tests__/assemble-video-input-cap.test.ts +442 -0
- package/src/__tests__/assemble-video-input.test.ts +167 -33
- package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
- package/src/__tests__/entity-convergence-image.test.ts +374 -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__/prompt-style-section.test.ts +345 -0
- package/src/__tests__/read-node-subject.test.ts +140 -0
- package/src/__tests__/style-section-boundary.test.ts +179 -0
- package/src/__tests__/subject-fold.test.ts +251 -0
- package/src/__tests__/subject-registry.test.ts +312 -0
- package/src/assemble-image-input.ts +169 -41
- package/src/assemble-video-input.ts +200 -25
- package/src/direction-registry.ts +116 -28
- package/src/hint-shedding.ts +87 -0
- package/src/index.ts +3 -0
- package/src/parameter-prompt-hint.ts +8 -7
- package/src/picker-catalogs.ts +14 -7
- package/src/prompt-builder.ts +628 -88
- package/src/prompt-hint-join.ts +9 -0
- package/src/prompt-style-section.ts +256 -0
- package/src/read-node-direction.ts +60 -1
- package/src/subject-registry.ts +464 -0
- 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
|
+
})
|