@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,442 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { composeVideoPromptText } from "../assemble-video-input.js"
|
|
3
|
+
import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
|
|
4
|
+
import { renderDirectionHints, VIDEO_HINT_MODE_DEFAULT } from "../direction-registry.js"
|
|
5
|
+
import { renderSubjectHints, SUBJECT_VIDEO_HINT_MODE_DEFAULT } from "../subject-registry.js"
|
|
6
|
+
import { renderStructuredFields } from "../prompt-builder-structured-fields.js"
|
|
7
|
+
import {
|
|
8
|
+
composeSectionedPrompt,
|
|
9
|
+
partitionStyleClauses,
|
|
10
|
+
renderStyleSection,
|
|
11
|
+
} from "../prompt-style-section.js"
|
|
12
|
+
import { joinPromptHints } from "../prompt-hint-join.js"
|
|
13
|
+
import { getMaxVideoPromptChars } from "@nodaro/shared"
|
|
14
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* TRUNCATION ORDERING, VIDEO HALF — `composeVideoPromptText` sheds its own hint
|
|
18
|
+
* clauses before the provider clamp's ORDER-BLIND tail cut can reach a
|
|
19
|
+
* reference binding or the user's prose. The image twin is
|
|
20
|
+
* `assemble-image-input-cap.test.ts`; this suite mirrors its shape.
|
|
21
|
+
*
|
|
22
|
+
* THE ORDERING PROBLEM THIS SIDE HAS AND THE IMAGE SIDE DID NOT: the fold runs
|
|
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.
|
|
30
|
+
*
|
|
31
|
+
* Video caps are far tighter than the image side's (kling = 1000 vs seedream =
|
|
32
|
+
* 3000), so an ordinary direction overflows without any contrived prose.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// A broad but ordinary video direction — the kind a "set every picker" UI emits.
|
|
36
|
+
// Ids that don't resolve contribute nothing (registry tolerance); what matters
|
|
37
|
+
// is that the fold is large enough to overflow kling.
|
|
38
|
+
const DIRECTION = {
|
|
39
|
+
cameraMotion: "handheld",
|
|
40
|
+
shotSize: "wide-shot",
|
|
41
|
+
angle: "low-angle",
|
|
42
|
+
timeOfDay: "golden-hour",
|
|
43
|
+
lightingStyle: "rembrandt",
|
|
44
|
+
colorLook: "teal-orange",
|
|
45
|
+
atmosphere: ["fog"],
|
|
46
|
+
style: "cinematic",
|
|
47
|
+
mood: ["happy", "joyful"],
|
|
48
|
+
setting: "forest",
|
|
49
|
+
} as const
|
|
50
|
+
|
|
51
|
+
const VIDEO_HINTS = renderDirectionHints(DIRECTION, {
|
|
52
|
+
surface: "video",
|
|
53
|
+
mode: VIDEO_HINT_MODE_DEFAULT,
|
|
54
|
+
})
|
|
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
|
+
|
|
65
|
+
/** The mentioned character — hybrid replaces the mention INLINE, mid-prose. */
|
|
66
|
+
const KIRA: ConnectedReference = {
|
|
67
|
+
id: "kira-id",
|
|
68
|
+
defaultName: "Kira",
|
|
69
|
+
source: "wired-character",
|
|
70
|
+
url: "https://r2.example/kira.png",
|
|
71
|
+
characterSlug: "kira",
|
|
72
|
+
variantSlug: undefined,
|
|
73
|
+
characterCanonicalDescription: "a young woman with copper hair",
|
|
74
|
+
variantDescription: null,
|
|
75
|
+
variantDisplayName: "canonical",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** An UNMENTIONED wired character — hybrid renders its canonical-fallback role
|
|
79
|
+
* phrase at the very END, behind every folded hint. The order-blind casualty. */
|
|
80
|
+
const RAY: ConnectedReference = {
|
|
81
|
+
id: "ray-id",
|
|
82
|
+
defaultName: "Ray",
|
|
83
|
+
source: "wired-character",
|
|
84
|
+
url: "https://r2.example/ray.png",
|
|
85
|
+
characterSlug: "ray",
|
|
86
|
+
variantSlug: undefined,
|
|
87
|
+
characterCanonicalDescription: "a grizzled dockworker",
|
|
88
|
+
variantDescription: null,
|
|
89
|
+
variantDisplayName: "canonical",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const KLING_CAP = getMaxVideoPromptChars("kling")
|
|
93
|
+
|
|
94
|
+
/** Prose long enough that prose + bindings + the full fold clears 1000. */
|
|
95
|
+
const PROSE = "@kira:1 walks the seawall at dusk. " + "The waves are loud. ".repeat(14)
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The FRAME under test: the production reference resolver in the hybrid format,
|
|
99
|
+
* exactly the shape the routes' `assembleVideoConnectedReferences` drives it in.
|
|
100
|
+
* Pure, so the composer may call it once per shed iteration.
|
|
101
|
+
*/
|
|
102
|
+
const frame = (body: string | undefined): string | undefined =>
|
|
103
|
+
resolveVideoReferenceCore({
|
|
104
|
+
prompt: body,
|
|
105
|
+
wiredCharRefs: [KIRA, RAY],
|
|
106
|
+
hybridRoles: true,
|
|
107
|
+
}).prompt
|
|
108
|
+
|
|
109
|
+
/** The binding that lands inline, inside the prose. */
|
|
110
|
+
const MENTION_BINDING = "@image_1"
|
|
111
|
+
/** Ray is unmentioned → his canonical-fallback phrase ends the BODY, spliced in
|
|
112
|
+
* ahead of the `[style]` section. */
|
|
113
|
+
const TRAILING_BINDING = "@image_2"
|
|
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
|
+
|
|
121
|
+
describe("composeVideoPromptText — cap-aware hint shedding", () => {
|
|
122
|
+
it("the unshed fold really does overflow kling through the frame (non-vacuity guard)", () => {
|
|
123
|
+
// The oracle for "what the composer produced before": fold every hint, hand
|
|
124
|
+
// the body to the resolver, let the provider clamp decide. If catalog
|
|
125
|
+
// wording ever shrinks enough that this stops overflowing, every scenario
|
|
126
|
+
// below is vacuous and this assertion says so loudly.
|
|
127
|
+
const naive = frame(composeVideoPromptText(PROSE, DIRECTION))!
|
|
128
|
+
expect(naive.length).toBeGreaterThan(KLING_CAP)
|
|
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)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("keeps every binding and the full prose, dropping trailing hints", () => {
|
|
137
|
+
const body = composeVideoPromptText(PROSE, DIRECTION, undefined, {
|
|
138
|
+
cap: KLING_CAP,
|
|
139
|
+
frame,
|
|
140
|
+
})!
|
|
141
|
+
const framed = frame(body)!
|
|
142
|
+
|
|
143
|
+
// Fits — the shed resolved the whole overflow, so the provider clamp is
|
|
144
|
+
// never reached and nothing is cut mid-word.
|
|
145
|
+
expect(framed.length).toBeLessThanOrEqual(KLING_CAP)
|
|
146
|
+
|
|
147
|
+
// Both bindings survive: the mention resolved inline AND the trailing
|
|
148
|
+
// canonical-fallback role phrase the resolver appends last.
|
|
149
|
+
expect(framed).toContain(MENTION_BINDING)
|
|
150
|
+
expect(framed).toContain(TRAILING_BINDING)
|
|
151
|
+
|
|
152
|
+
// The user's prose survives IN FULL and still leads the body (the hint join
|
|
153
|
+
// trims its trailing space), and it survives the framing too — the mention
|
|
154
|
+
// resolving inline to its role phrase is the only edit it takes.
|
|
155
|
+
expect(body.startsWith(PROSE.trim())).toBe(true)
|
|
156
|
+
expect(framed).toContain(PROSE.replace("@kira:1", `the person from ${MENTION_BINDING}`).trim())
|
|
157
|
+
|
|
158
|
+
// The LAST-folded hint clause is gone; the FIRST-folded one stayed. Shedding
|
|
159
|
+
// walks the fold order from the tail, so the dimensions the registry folds
|
|
160
|
+
// first outlive the ones it folds last.
|
|
161
|
+
expect(body).not.toContain(VIDEO_HINTS[VIDEO_HINTS.length - 1])
|
|
162
|
+
expect(body).toContain(VIDEO_HINTS[0])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("sheds more as the cap tightens, and everything at a cap prose alone can't meet", () => {
|
|
166
|
+
const roomy = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: KLING_CAP, frame })!
|
|
167
|
+
const tight = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: 700, frame })!
|
|
168
|
+
expect(tight.length).toBeLessThan(roomy.length)
|
|
169
|
+
|
|
170
|
+
// A cap the framed prose alone cannot meet sheds every hint and then stops —
|
|
171
|
+
// prose is never touched, and the provider clamp stays the last resort.
|
|
172
|
+
const starved = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: 10, frame })
|
|
173
|
+
expect(starved).toBe(PROSE)
|
|
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]")
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("reserves the caller's budget rather than re-deriving a provider cap", () => {
|
|
225
|
+
// The routes pass `effectiveVideoPromptCeiling`, which for a NON-native
|
|
226
|
+
// negative provider is `cap - "\nAvoid: …".length`. The composer must honor
|
|
227
|
+
// that reduced number verbatim — a composer that read `getMaxVideoPromptChars`
|
|
228
|
+
// itself would shed too little and let the clamp sever the Avoid suffix's
|
|
229
|
+
// room. `minimax` is outside NATIVE_NEGATIVE_VIDEO_PROVIDERS, so its ceiling
|
|
230
|
+
// really does shrink.
|
|
231
|
+
const rawCap = getMaxVideoPromptChars("minimax")
|
|
232
|
+
const reserved = rawCap - "\nAvoid: blurry, low quality".length
|
|
233
|
+
const atRaw = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: rawCap, frame })!
|
|
234
|
+
const atReserved = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: reserved, frame })!
|
|
235
|
+
expect(frame(atReserved)!.length).toBeLessThanOrEqual(reserved)
|
|
236
|
+
expect(atReserved.length).toBeLessThanOrEqual(atRaw.length)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("never sheds the structured fragment — it is user content, not a garnish", () => {
|
|
240
|
+
const structured = { person: { profession: "a lighthouse keeper", expression: "focused" } }
|
|
241
|
+
const fragment = renderStructuredFields(structured)
|
|
242
|
+
expect(fragment.length, "an empty fragment makes every claim below vacuous").toBeGreaterThan(0)
|
|
243
|
+
const body = composeVideoPromptText(PROSE, DIRECTION, structured, { cap: 700, frame })!
|
|
244
|
+
expect(body).toContain(fragment)
|
|
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)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe("composeVideoPromptText — under-cap byte parity", () => {
|
|
263
|
+
// The oracle is literally the capless call. A framed prompt that FITS must be
|
|
264
|
+
// byte-identical to it, so the whole leg lands dark for every under-cap run.
|
|
265
|
+
const parityCases: ReadonlyArray<{ name: string; provider: string; prompt: string }> = [
|
|
266
|
+
{ name: "high-cap provider with the same fold", provider: "seedance-2", prompt: PROSE },
|
|
267
|
+
{ name: "low-cap provider, short prose", provider: "kling", prompt: "a knight on a hill" },
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
for (const { name, provider, prompt } of parityCases) {
|
|
271
|
+
it(`is byte-identical to the capless fold — ${name}`, () => {
|
|
272
|
+
const cap = getMaxVideoPromptChars(provider)
|
|
273
|
+
const expected = composeVideoPromptText(prompt, DIRECTION)
|
|
274
|
+
const actual = composeVideoPromptText(prompt, DIRECTION, undefined, { cap, frame })
|
|
275
|
+
expect(actual).toBe(expected)
|
|
276
|
+
// Guard the guard: a case that overflowed would prove nothing.
|
|
277
|
+
expect(frame(expected)!.length).toBeLessThanOrEqual(cap)
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
it("leaves the no-direction platform-caller path an exact no-op under a cap", () => {
|
|
282
|
+
// No hints → nothing droppable → the prompt comes back verbatim and
|
|
283
|
+
// UNTRIMMED, `undefined` included, exactly as before, even on a tiny cap.
|
|
284
|
+
expect(composeVideoPromptText(" \n", undefined, undefined, { cap: 1, frame })).toBe(" \n")
|
|
285
|
+
expect(composeVideoPromptText(undefined, undefined, undefined, { cap: 1, frame })).toBeUndefined()
|
|
286
|
+
expect(composeVideoPromptText("a knight", {}, undefined, { cap: 1, frame })).toBe("a knight")
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it("treats an absent frame as identity, and an absent cap as no shedding at all", () => {
|
|
290
|
+
// A caller with a cap but no references measures the body itself…
|
|
291
|
+
const framedless = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: 400 })!
|
|
292
|
+
expect(framedless.length).toBeLessThanOrEqual(400)
|
|
293
|
+
// …and a caller with no cap gets the full fold no matter how long it is.
|
|
294
|
+
expect(composeVideoPromptText(PROSE, DIRECTION, undefined, { frame })).toBe(
|
|
295
|
+
composeVideoPromptText(PROSE, DIRECTION),
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* THE SUBJECT CHANNEL UNDER THE SAME CAP. The decision this pins: subject
|
|
302
|
+
* clauses ARE shed candidates, exactly like direction clauses — both are
|
|
303
|
+
* catalog decoration the platform rendered from ids — and they shed AFTER the
|
|
304
|
+
* direction fold, because both channels ride ONE list (`[...subject,
|
|
305
|
+
* ...direction]`) that the shared `hint-shedding.ts` arithmetic walks TAIL
|
|
306
|
+
* FIRST. Exempting the subject fold would not save it: a fully specified person
|
|
307
|
+
* is the largest single fold on the surface, so the overflow would simply land
|
|
308
|
+
* in the provider's order-blind clamp and sever a binding or the prose instead.
|
|
309
|
+
*
|
|
310
|
+
* The image twin of this ordering is pinned in `subject-fold.test.ts`; what is
|
|
311
|
+
* new HERE is the composition the video surface only gained once the cap and
|
|
312
|
+
* the subject channel met — including the SUBJECT-ONLY fold, which neither
|
|
313
|
+
* channel's own suite exercises under a cap.
|
|
314
|
+
*
|
|
315
|
+
* Caps are derived from the framed body rather than hardcoded, so the cases
|
|
316
|
+
* keep testing the claim when catalog wording changes.
|
|
317
|
+
*/
|
|
318
|
+
describe("composeVideoPromptText — the subject fold under the cap", () => {
|
|
319
|
+
const SUBJECT = {
|
|
320
|
+
type: "woman",
|
|
321
|
+
ethnicity: "east-asian",
|
|
322
|
+
hairBase: "base-short-straight",
|
|
323
|
+
makeup: "makeup-smoky",
|
|
324
|
+
animal: "dog-corgi",
|
|
325
|
+
} as const
|
|
326
|
+
|
|
327
|
+
const SUBJECT_HINTS = renderSubjectHints(SUBJECT, {
|
|
328
|
+
surface: "video",
|
|
329
|
+
mode: SUBJECT_VIDEO_HINT_MODE_DEFAULT,
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Framed length of the body that folds exactly the first `n` subject clauses
|
|
334
|
+
* and no direction at all — i.e. the tightest budget under which the shed
|
|
335
|
+
* should settle on `n` kept clauses. Built the same way the composer builds
|
|
336
|
+
* it, so the derived caps track catalog wording instead of pinning it.
|
|
337
|
+
*/
|
|
338
|
+
const framedWithSubjectClauses = (n: number): number =>
|
|
339
|
+
frame(n === 0 ? PROSE : joinPromptHints(PROSE, SUBJECT_HINTS.slice(0, n)))!.length
|
|
340
|
+
|
|
341
|
+
it("sheds the whole direction fold before a single subject clause", () => {
|
|
342
|
+
// Non-vacuity: subject + direction together really do overflow the frame.
|
|
343
|
+
const unshed = frame(
|
|
344
|
+
composeVideoPromptText(PROSE, DIRECTION, undefined, { subject: SUBJECT }),
|
|
345
|
+
)!
|
|
346
|
+
expect(unshed.length).toBeGreaterThan(KLING_CAP)
|
|
347
|
+
expect(SUBJECT_HINTS.length).toBeGreaterThan(1)
|
|
348
|
+
|
|
349
|
+
// A budget that fits the prose plus the FULL subject fold and nothing else.
|
|
350
|
+
const cap = framedWithSubjectClauses(SUBJECT_HINTS.length)
|
|
351
|
+
const body = composeVideoPromptText(PROSE, DIRECTION, undefined, {
|
|
352
|
+
subject: SUBJECT,
|
|
353
|
+
cap,
|
|
354
|
+
frame,
|
|
355
|
+
})!
|
|
356
|
+
expect(frame(body)!.length).toBeLessThanOrEqual(cap)
|
|
357
|
+
// Every subject clause survived; the direction fold paid the whole bill.
|
|
358
|
+
for (const hint of SUBJECT_HINTS) expect(body).toContain(hint)
|
|
359
|
+
for (const hint of VIDEO_HINTS) expect(body).not.toContain(hint)
|
|
360
|
+
// …and the prose and both bindings are untouched, as always.
|
|
361
|
+
expect(body.startsWith(PROSE.trim())).toBe(true)
|
|
362
|
+
expect(frame(body)!).toContain(MENTION_BINDING)
|
|
363
|
+
expect(frame(body)!).toContain(TRAILING_BINDING)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it("then sheds subject clauses too, last-folded first, once direction is gone", () => {
|
|
367
|
+
// Tighter than the prose plus the full subject fold → the tail of the
|
|
368
|
+
// SUBJECT list has to go as well. This is the decision: subject clauses are
|
|
369
|
+
// shed candidates, not a protected channel.
|
|
370
|
+
const cap = framedWithSubjectClauses(SUBJECT_HINTS.length - 1)
|
|
371
|
+
const body = composeVideoPromptText(PROSE, DIRECTION, undefined, {
|
|
372
|
+
subject: SUBJECT,
|
|
373
|
+
cap,
|
|
374
|
+
frame,
|
|
375
|
+
})!
|
|
376
|
+
expect(frame(body)!.length).toBeLessThanOrEqual(cap)
|
|
377
|
+
expect(body).not.toContain(SUBJECT_HINTS[SUBJECT_HINTS.length - 1])
|
|
378
|
+
expect(body).toContain(SUBJECT_HINTS[0])
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it("sheds a SUBJECT-ONLY fold, and still never the prose or the bindings", () => {
|
|
382
|
+
// The composition neither parent shipped: `subject` with a cap and no
|
|
383
|
+
// `direction` at all — the shape a subject-only route request takes.
|
|
384
|
+
const cap = framedWithSubjectClauses(0)
|
|
385
|
+
const body = composeVideoPromptText(PROSE, undefined, undefined, {
|
|
386
|
+
subject: SUBJECT,
|
|
387
|
+
cap,
|
|
388
|
+
frame,
|
|
389
|
+
})!
|
|
390
|
+
// Everything droppable is gone, so the body is the prose VERBATIM (the
|
|
391
|
+
// `kept === 0` no-op branch), which is what leaves the route's
|
|
392
|
+
// `composed !== prompt` guard correctly unpinned.
|
|
393
|
+
expect(body).toBe(PROSE)
|
|
394
|
+
for (const hint of SUBJECT_HINTS) expect(body).not.toContain(hint)
|
|
395
|
+
const framed = frame(body)!
|
|
396
|
+
expect(framed).toContain(MENTION_BINDING)
|
|
397
|
+
expect(framed).toContain(TRAILING_BINDING)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it("never sheds the structured fragment to save a subject clause", () => {
|
|
401
|
+
// Ordering across ALL THREE pieces at once: user content outranks both
|
|
402
|
+
// catalog channels and still lands last.
|
|
403
|
+
const structured = { person: { profession: "a lighthouse keeper", expression: "focused" } }
|
|
404
|
+
const fragment = renderStructuredFields(structured)
|
|
405
|
+
const body = composeVideoPromptText(PROSE, DIRECTION, structured, {
|
|
406
|
+
subject: SUBJECT,
|
|
407
|
+
cap: framedWithSubjectClauses(0),
|
|
408
|
+
frame,
|
|
409
|
+
})!
|
|
410
|
+
expect(bodyOf(body).endsWith(fragment)).toBe(true)
|
|
411
|
+
for (const hint of [...SUBJECT_HINTS, ...VIDEO_HINTS]) {
|
|
412
|
+
expect(body).not.toContain(hint)
|
|
413
|
+
}
|
|
414
|
+
// Everything droppable went, so there is no section left to end with.
|
|
415
|
+
expect(body).not.toContain("[style]")
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it("is byte-identical to the capless subject fold when it fits", () => {
|
|
419
|
+
// The under-cap parity oracle, extended to the subject channel: a fold that
|
|
420
|
+
// fits must not be able to tell it was budgeted.
|
|
421
|
+
const short = "a knight on a hill"
|
|
422
|
+
const cap = getMaxVideoPromptChars("seedance-2")
|
|
423
|
+
const expected = composeVideoPromptText(short, DIRECTION, undefined, { subject: SUBJECT })
|
|
424
|
+
const actual = composeVideoPromptText(short, DIRECTION, undefined, {
|
|
425
|
+
subject: SUBJECT,
|
|
426
|
+
cap,
|
|
427
|
+
frame,
|
|
428
|
+
})
|
|
429
|
+
expect(actual).toBe(expected)
|
|
430
|
+
expect(frame(expected)!.length).toBeLessThanOrEqual(cap)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it("leaves a subject-less request byte-identical under a cap (the parity oracle)", () => {
|
|
434
|
+
// The channel must stay dark: no `subject` → exactly what the direction-only
|
|
435
|
+
// cap path produced before the two met.
|
|
436
|
+
for (const cap of [KLING_CAP, 700, 10]) {
|
|
437
|
+
expect(composeVideoPromptText(PROSE, DIRECTION, undefined, { cap, frame })).toBe(
|
|
438
|
+
composeVideoPromptText(PROSE, DIRECTION, undefined, { subject: {}, cap, frame }),
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
})
|