@nodaro/prompts 1.12.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 +303 -187
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +233 -28
- package/dist/index.d.ts +233 -28
- package/dist/index.js +290 -188
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-image-input-cap.test.ts +37 -13
- package/src/__tests__/assemble-image-input.test.ts +100 -19
- package/src/__tests__/assemble-video-input-cap.test.ts +101 -15
- package/src/__tests__/assemble-video-input.test.ts +167 -33
- package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
- package/src/__tests__/prompt-style-section.test.ts +345 -0
- package/src/__tests__/style-section-boundary.test.ts +179 -0
- package/src/__tests__/subject-fold.test.ts +32 -13
- package/src/assemble-image-input.ts +51 -26
- package/src/assemble-video-input.ts +54 -34
- package/src/direction-registry.ts +108 -37
- package/src/hint-shedding.ts +23 -4
- package/src/index.ts +1 -0
- package/src/prompt-builder.ts +84 -27
- package/src/prompt-hint-join.ts +9 -0
- package/src/prompt-style-section.ts +256 -0
- 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.
|
|
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.
|
|
23
|
+
"@nodaro/shared": "^2.18.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.5.0",
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"
|
|
|
2
2
|
import { assembleImageInput } from "../assemble-image-input.js"
|
|
3
3
|
import { buildImagePrompt, buildImagePromptWithOverflow } from "../prompt-builder.js"
|
|
4
4
|
import { renderDirectionHints, IMAGE_HINT_MODE_DEFAULT } from "../direction-registry.js"
|
|
5
|
-
import {
|
|
5
|
+
import { renderStyleSection } from "../prompt-style-section.js"
|
|
6
6
|
import { getMaxImagePromptChars } from "@nodaro/shared"
|
|
7
7
|
import type { ConnectedReference } from "@nodaro/shared"
|
|
8
8
|
|
|
@@ -14,11 +14,15 @@ import type { ConnectedReference } from "@nodaro/shared"
|
|
|
14
14
|
* TRULY maximal image-surface `direction` renders ~3.3K characters of clauses —
|
|
15
15
|
* the broad-but-not-maximal fold below renders ~1.2K, which is already enough
|
|
16
16
|
* to push the assembled prompt past a low-cap provider (seedream = 3000).
|
|
17
|
-
* `buildImagePrompt` then cuts the TAIL
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
22
26
|
*/
|
|
23
27
|
|
|
24
28
|
// A broad but ordinary image direction — the kind a "set every picker" UI
|
|
@@ -45,6 +49,18 @@ const IMAGE_HINTS = renderDirectionHints(DIRECTION, {
|
|
|
45
49
|
mode: IMAGE_HINT_MODE_DEFAULT,
|
|
46
50
|
})
|
|
47
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
|
+
|
|
48
64
|
/** The mentioned character — its directive is the FIRST binding in the prompt. */
|
|
49
65
|
const KIRA: ConnectedReference = {
|
|
50
66
|
id: "kira-id",
|
|
@@ -58,8 +74,8 @@ const KIRA: ConnectedReference = {
|
|
|
58
74
|
variantDisplayName: "canonical",
|
|
59
75
|
}
|
|
60
76
|
|
|
61
|
-
/** An UNMENTIONED wired location — hybrid renders its role phrase
|
|
62
|
-
*
|
|
77
|
+
/** An UNMENTIONED wired location — hybrid renders its role phrase as the last
|
|
78
|
+
* line of the BODY, just ahead of the `[style]` section. */
|
|
63
79
|
const PIER: ConnectedReference = {
|
|
64
80
|
id: "pier-id",
|
|
65
81
|
defaultName: "Pier",
|
|
@@ -70,9 +86,15 @@ const PIER: ConnectedReference = {
|
|
|
70
86
|
|
|
71
87
|
/** The mention-resolved character binding, in the middle of the prose. */
|
|
72
88
|
const CHARACTER_BINDING = "the person from reference image A"
|
|
73
|
-
/** The binding the tail cut
|
|
89
|
+
/** The binding the tail cut used to destroy when nothing shed the hints first. */
|
|
74
90
|
const LOCATION_BINDING = "the location from reference image B"
|
|
75
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
|
+
|
|
76
98
|
/** Prose long enough that prose + directives + the full fold clears 3000. */
|
|
77
99
|
const PROSE = "@kira:1 walks the seawall at dusk. " + "The waves are loud. ".repeat(90)
|
|
78
100
|
|
|
@@ -93,14 +115,16 @@ describe("assembleImageInput — cap-aware hint shedding", () => {
|
|
|
93
115
|
// ever shrinks enough that this no longer truncates, the scenario below is
|
|
94
116
|
// vacuous and this assertion says so loudly.
|
|
95
117
|
const naive = buildImagePrompt({
|
|
96
|
-
prompt:
|
|
118
|
+
prompt: unshedBody(PROSE),
|
|
97
119
|
provider: "seedream",
|
|
98
120
|
connectedReferences: [KIRA, PIER],
|
|
99
121
|
referenceFormat: "hybrid",
|
|
100
122
|
})
|
|
101
123
|
expect(naive.prompt.endsWith("...")).toBe(true)
|
|
102
|
-
// …and what it cut was the
|
|
103
|
-
|
|
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)
|
|
104
128
|
})
|
|
105
129
|
|
|
106
130
|
it("keeps every reference binding and the full prose, dropping trailing hints", () => {
|
|
@@ -154,7 +178,7 @@ describe("assembleImageInput — under-cap byte parity", () => {
|
|
|
154
178
|
for (const { name, provider, prompt } of parityCases) {
|
|
155
179
|
it(`is byte-identical to the unordered fold — ${name}`, () => {
|
|
156
180
|
const expected = buildImagePrompt({
|
|
157
|
-
prompt:
|
|
181
|
+
prompt: unshedBody(prompt),
|
|
158
182
|
provider,
|
|
159
183
|
connectedReferences: [KIRA, PIER],
|
|
160
184
|
referenceFormat: "hybrid",
|
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
|
138
|
+
expect(result.prompt).toBe(`a knight\n\n[style]:\n${blended[0]}`)
|
|
135
139
|
})
|
|
136
140
|
|
|
137
|
-
it("
|
|
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
|
|
150
|
+
`a knight\n\n[style]:\n` +
|
|
151
|
+
`${getStylePromptHint("anime")}\n${getFramingPromptHint("wide-shot")}`,
|
|
145
152
|
)
|
|
146
153
|
})
|
|
147
154
|
|
|
148
|
-
it("
|
|
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
|
-
//
|
|
162
|
-
//
|
|
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
|
-
"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
|
@@ -4,6 +4,11 @@ import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
|
|
|
4
4
|
import { renderDirectionHints, VIDEO_HINT_MODE_DEFAULT } from "../direction-registry.js"
|
|
5
5
|
import { renderSubjectHints, SUBJECT_VIDEO_HINT_MODE_DEFAULT } from "../subject-registry.js"
|
|
6
6
|
import { renderStructuredFields } from "../prompt-builder-structured-fields.js"
|
|
7
|
+
import {
|
|
8
|
+
composeSectionedPrompt,
|
|
9
|
+
partitionStyleClauses,
|
|
10
|
+
renderStyleSection,
|
|
11
|
+
} from "../prompt-style-section.js"
|
|
7
12
|
import { joinPromptHints } from "../prompt-hint-join.js"
|
|
8
13
|
import { getMaxVideoPromptChars } from "@nodaro/shared"
|
|
9
14
|
import type { ConnectedReference } from "@nodaro/shared"
|
|
@@ -15,12 +20,13 @@ import type { ConnectedReference } from "@nodaro/shared"
|
|
|
15
20
|
* `assemble-image-input-cap.test.ts`; this suite mirrors its shape.
|
|
16
21
|
*
|
|
17
22
|
* THE ORDERING PROBLEM THIS SIDE HAS AND THE IMAGE SIDE DID NOT: the fold runs
|
|
18
|
-
* BEFORE `resolveVideoReferenceCore`, and the resolver then ADDS
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* BEFORE `resolveVideoReferenceCore`, and the resolver then ADDS binding text —
|
|
24
|
+
* lock lines ahead of the body, role phrases spliced in at the end of it, just
|
|
25
|
+
* before the `[style]` section. None of it is sheddable and all of it counts
|
|
26
|
+
* against the ceiling, so the shed is decided on the FRAMED length
|
|
27
|
+
* (`opts.frame`), not on the folded body: the resolver's additions are inside
|
|
28
|
+
* the budget, while the only thing the composer can drop is a clause it
|
|
29
|
+
* rendered itself. The frame below is the real resolver.
|
|
24
30
|
*
|
|
25
31
|
* Video caps are far tighter than the image side's (kling = 1000 vs seedream =
|
|
26
32
|
* 3000), so an ordinary direction overflows without any contrived prose.
|
|
@@ -47,6 +53,15 @@ const VIDEO_HINTS = renderDirectionHints(DIRECTION, {
|
|
|
47
53
|
mode: VIDEO_HINT_MODE_DEFAULT,
|
|
48
54
|
})
|
|
49
55
|
|
|
56
|
+
/** The same clauses, slotted — the shed keeps a PREFIX of exactly this list. */
|
|
57
|
+
const DIRECTION_CLAUSES = partitionStyleClauses(DIRECTION, {
|
|
58
|
+
surface: "video",
|
|
59
|
+
mode: VIDEO_HINT_MODE_DEFAULT,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
/** Everything before the `[style]` section — the half the shed budget grows. */
|
|
63
|
+
const bodyOf = (composed: string): string => composed.split("\n\n[style]:\n")[0]!
|
|
64
|
+
|
|
50
65
|
/** The mentioned character — hybrid replaces the mention INLINE, mid-prose. */
|
|
51
66
|
const KIRA: ConnectedReference = {
|
|
52
67
|
id: "kira-id",
|
|
@@ -93,9 +108,16 @@ const frame = (body: string | undefined): string | undefined =>
|
|
|
93
108
|
|
|
94
109
|
/** The binding that lands inline, inside the prose. */
|
|
95
110
|
const MENTION_BINDING = "@image_1"
|
|
96
|
-
/** Ray is unmentioned → his canonical-fallback phrase
|
|
111
|
+
/** Ray is unmentioned → his canonical-fallback phrase ends the BODY, spliced in
|
|
112
|
+
* ahead of the `[style]` section. */
|
|
97
113
|
const TRAILING_BINDING = "@image_2"
|
|
98
114
|
|
|
115
|
+
/** The whole look section, unshed — what an order-blind cut severs first. */
|
|
116
|
+
const FULL_SECTION = renderStyleSection(DIRECTION, {
|
|
117
|
+
surface: "video",
|
|
118
|
+
mode: VIDEO_HINT_MODE_DEFAULT,
|
|
119
|
+
})
|
|
120
|
+
|
|
99
121
|
describe("composeVideoPromptText — cap-aware hint shedding", () => {
|
|
100
122
|
it("the unshed fold really does overflow kling through the frame (non-vacuity guard)", () => {
|
|
101
123
|
// The oracle for "what the composer produced before": fold every hint, hand
|
|
@@ -104,9 +126,11 @@ describe("composeVideoPromptText — cap-aware hint shedding", () => {
|
|
|
104
126
|
// below is vacuous and this assertion says so loudly.
|
|
105
127
|
const naive = frame(composeVideoPromptText(PROSE, DIRECTION))!
|
|
106
128
|
expect(naive.length).toBeGreaterThan(KLING_CAP)
|
|
107
|
-
// …and what a tail cut at the cap would destroy is the
|
|
108
|
-
//
|
|
109
|
-
|
|
129
|
+
// …and what a tail cut at the cap would destroy is the look section,
|
|
130
|
+
// mid-clause: the role phrase splices into the body ahead of it and clears
|
|
131
|
+
// the cut, so the shed's job is to drop whole clauses instead.
|
|
132
|
+
expect(naive.slice(0, KLING_CAP)).toContain(TRAILING_BINDING)
|
|
133
|
+
expect(naive.slice(0, KLING_CAP)).not.toContain(FULL_SECTION)
|
|
110
134
|
})
|
|
111
135
|
|
|
112
136
|
it("keeps every binding and the full prose, dropping trailing hints", () => {
|
|
@@ -148,6 +172,53 @@ describe("composeVideoPromptText — cap-aware hint shedding", () => {
|
|
|
148
172
|
const starved = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: 10, frame })
|
|
149
173
|
expect(starved).toBe(PROSE)
|
|
150
174
|
for (const hint of VIDEO_HINTS) expect(starved).not.toContain(hint)
|
|
175
|
+
// A FULL shed takes the header with it — byte-identical to the prompt, not
|
|
176
|
+
// an empty section hanging off it. This is what keeps the routes'
|
|
177
|
+
// `composed !== prompt` guard reading false when nothing survived.
|
|
178
|
+
expect(starved).not.toContain("[style]")
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("survives the reference resolver byte-intact", () => {
|
|
182
|
+
// The resolver is why the section is written flush-left: it collapses 2+
|
|
183
|
+
// HORIZONTAL spaces unanchored, and it rewrites mentions and appends role
|
|
184
|
+
// phrases around the body. None of that may touch the section's bytes.
|
|
185
|
+
const body = composeVideoPromptText(PROSE, DIRECTION)!
|
|
186
|
+
const section = body.slice(body.indexOf("\n\n[style]:\n"))
|
|
187
|
+
expect(section).toContain("[style]:\n")
|
|
188
|
+
// ENDS with it, not merely contains it: the resolver's role phrases splice
|
|
189
|
+
// into the body ahead of the section, so nothing of the resolver's may
|
|
190
|
+
// extend the clause block the header opens.
|
|
191
|
+
expect(frame(body)!.endsWith(section)).toBe(true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("reclaims the header only when the LAST look clause sheds", () => {
|
|
195
|
+
// Budgets derived from what the composer actually builds, so they track
|
|
196
|
+
// catalog wording instead of pinning it.
|
|
197
|
+
const capForKept = (n: number): number =>
|
|
198
|
+
frame(composeSectionedPrompt(PROSE, DIRECTION_CLAUSES.slice(0, n), ""))!.length
|
|
199
|
+
// The first clause is `cameraMotion` (motion → body), the second the first
|
|
200
|
+
// LOOK clause — so `kept = 2` is "body plus exactly one section clause".
|
|
201
|
+
expect(DIRECTION_CLAUSES[0]!.slot).toBe("body")
|
|
202
|
+
expect(DIRECTION_CLAUSES[1]!.slot).not.toBe("body")
|
|
203
|
+
|
|
204
|
+
const atTwo = composeVideoPromptText(PROSE, DIRECTION, undefined, {
|
|
205
|
+
cap: capForKept(2),
|
|
206
|
+
frame,
|
|
207
|
+
})!
|
|
208
|
+
expect(atTwo).toBe(composeSectionedPrompt(PROSE, DIRECTION_CLAUSES.slice(0, 2), ""))
|
|
209
|
+
expect(atTwo).toContain("[style]:")
|
|
210
|
+
|
|
211
|
+
// ONE byte tighter, and the section's last clause goes — taking the whole
|
|
212
|
+
// 11-byte `"\n\n[style]:\n"` with it, so the body drops all the way back to
|
|
213
|
+
// the prose. (The cost of under-pricing that header instead shows up as an
|
|
214
|
+
// over-shed in "sheds the whole direction fold before a single subject
|
|
215
|
+
// clause" below, which is where a flat per-clause charge fails.)
|
|
216
|
+
const justUnder = composeVideoPromptText(PROSE, DIRECTION, undefined, {
|
|
217
|
+
cap: capForKept(2) - 1,
|
|
218
|
+
frame,
|
|
219
|
+
})!
|
|
220
|
+
expect(justUnder).toBe(composeSectionedPrompt(PROSE, DIRECTION_CLAUSES.slice(0, 1), ""))
|
|
221
|
+
expect(justUnder).not.toContain("[style]")
|
|
151
222
|
})
|
|
152
223
|
|
|
153
224
|
it("reserves the caller's budget rather than re-deriving a provider cap", () => {
|
|
@@ -166,12 +237,25 @@ describe("composeVideoPromptText — cap-aware hint shedding", () => {
|
|
|
166
237
|
})
|
|
167
238
|
|
|
168
239
|
it("never sheds the structured fragment — it is user content, not a garnish", () => {
|
|
169
|
-
const structured = {
|
|
240
|
+
const structured = { person: { profession: "a lighthouse keeper", expression: "focused" } }
|
|
170
241
|
const fragment = renderStructuredFields(structured)
|
|
242
|
+
expect(fragment.length, "an empty fragment makes every claim below vacuous").toBeGreaterThan(0)
|
|
171
243
|
const body = composeVideoPromptText(PROSE, DIRECTION, structured, { cap: 700, frame })!
|
|
172
244
|
expect(body).toContain(fragment)
|
|
173
|
-
// …and it still
|
|
174
|
-
expect(body.endsWith(fragment)).toBe(true)
|
|
245
|
+
// …and it still ends the BODY, behind every surviving body hint.
|
|
246
|
+
expect(bodyOf(body).endsWith(fragment)).toBe(true)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it("ends the BODY with the fragment even when the section survives above it", () => {
|
|
250
|
+
// The capless fold, where every clause lives: the fragment is the last
|
|
251
|
+
// thing in the body and the section reads after it, so the composed prompt
|
|
252
|
+
// does NOT end with the fragment any more.
|
|
253
|
+
const structured = { person: { profession: "a lighthouse keeper", expression: "focused" } }
|
|
254
|
+
const fragment = renderStructuredFields(structured)
|
|
255
|
+
const body = composeVideoPromptText(PROSE, DIRECTION, structured)!
|
|
256
|
+
expect(body).toContain("\n\n[style]:\n")
|
|
257
|
+
expect(bodyOf(body).endsWith(fragment)).toBe(true)
|
|
258
|
+
expect(body.endsWith(fragment)).toBe(false)
|
|
175
259
|
})
|
|
176
260
|
})
|
|
177
261
|
|
|
@@ -316,17 +400,19 @@ describe("composeVideoPromptText — the subject fold under the cap", () => {
|
|
|
316
400
|
it("never sheds the structured fragment to save a subject clause", () => {
|
|
317
401
|
// Ordering across ALL THREE pieces at once: user content outranks both
|
|
318
402
|
// catalog channels and still lands last.
|
|
319
|
-
const structured = {
|
|
403
|
+
const structured = { person: { profession: "a lighthouse keeper", expression: "focused" } }
|
|
320
404
|
const fragment = renderStructuredFields(structured)
|
|
321
405
|
const body = composeVideoPromptText(PROSE, DIRECTION, structured, {
|
|
322
406
|
subject: SUBJECT,
|
|
323
407
|
cap: framedWithSubjectClauses(0),
|
|
324
408
|
frame,
|
|
325
409
|
})!
|
|
326
|
-
expect(body.endsWith(fragment)).toBe(true)
|
|
410
|
+
expect(bodyOf(body).endsWith(fragment)).toBe(true)
|
|
327
411
|
for (const hint of [...SUBJECT_HINTS, ...VIDEO_HINTS]) {
|
|
328
412
|
expect(body).not.toContain(hint)
|
|
329
413
|
}
|
|
414
|
+
// Everything droppable went, so there is no section left to end with.
|
|
415
|
+
expect(body).not.toContain("[style]")
|
|
330
416
|
})
|
|
331
417
|
|
|
332
418
|
it("is byte-identical to the capless subject fold when it fits", () => {
|