@nodaro/prompts 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/index.cjs +693 -53
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1084 -27
  4. package/dist/index.d.ts +1084 -27
  5. package/dist/index.js +664 -55
  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 +212 -0
  11. package/src/__tests__/assemble-image-input.test.ts +93 -3
  12. package/src/__tests__/assemble-video-input-cap.test.ts +356 -0
  13. package/src/__tests__/assemble-video-input.test.ts +301 -0
  14. package/src/__tests__/direction-hint-token-safety.test.ts +113 -0
  15. package/src/__tests__/direction-registry.test.ts +393 -0
  16. package/src/__tests__/entity-convergence-image.test.ts +374 -0
  17. package/src/__tests__/image-convergence-image.test.ts +370 -0
  18. package/src/__tests__/location-convergence-image.test.ts +29 -1
  19. package/src/__tests__/location-default-role-image.test.ts +166 -0
  20. package/src/__tests__/mention-splice-spacing.test.ts +257 -0
  21. package/src/__tests__/read-node-direction.test.ts +154 -0
  22. package/src/__tests__/read-node-subject.test.ts +140 -0
  23. package/src/__tests__/subject-fold.test.ts +232 -0
  24. package/src/__tests__/subject-registry.test.ts +312 -0
  25. package/src/assemble-image-input.ts +160 -58
  26. package/src/assemble-video-input.ts +244 -0
  27. package/src/direction-registry.ts +371 -0
  28. package/src/hint-shedding.ts +68 -0
  29. package/src/index.ts +10 -2
  30. package/src/parameter-prompt-hint.ts +8 -7
  31. package/src/picker-catalogs.ts +14 -7
  32. package/src/prompt-builder.ts +728 -58
  33. package/src/prompt-hint-join.ts +30 -0
  34. package/src/read-node-direction.ts +233 -0
  35. package/src/subject-registry.ts +464 -0
@@ -0,0 +1,356 @@
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 { joinPromptHints } from "../prompt-hint-join.js"
8
+ import { getMaxVideoPromptChars } from "@nodaro/shared"
9
+ import type { ConnectedReference } from "@nodaro/shared"
10
+
11
+ /**
12
+ * TRUNCATION ORDERING, VIDEO HALF — `composeVideoPromptText` sheds its own hint
13
+ * clauses before the provider clamp's ORDER-BLIND tail cut can reach a
14
+ * reference binding or the user's prose. The image twin is
15
+ * `assemble-image-input-cap.test.ts`; this suite mirrors its shape.
16
+ *
17
+ * THE ORDERING PROBLEM THIS SIDE HAS AND THE IMAGE SIDE DID NOT: the fold runs
18
+ * BEFORE `resolveVideoReferenceCore`, and the resolver then ADDS the binding
19
+ * text — hybrid's role phrases are APPENDED, so they sit behind every folded
20
+ * hint and are the first thing a tail cut destroys. So the shed is decided on
21
+ * the FRAMED length (`opts.frame`), not on the folded body: the resolver's
22
+ * additions are inside the budget, while the only thing the composer can drop
23
+ * is a clause it rendered itself. The frame below is the real resolver.
24
+ *
25
+ * Video caps are far tighter than the image side's (kling = 1000 vs seedream =
26
+ * 3000), so an ordinary direction overflows without any contrived prose.
27
+ */
28
+
29
+ // A broad but ordinary video direction — the kind a "set every picker" UI emits.
30
+ // Ids that don't resolve contribute nothing (registry tolerance); what matters
31
+ // is that the fold is large enough to overflow kling.
32
+ const DIRECTION = {
33
+ cameraMotion: "handheld",
34
+ shotSize: "wide-shot",
35
+ angle: "low-angle",
36
+ timeOfDay: "golden-hour",
37
+ lightingStyle: "rembrandt",
38
+ colorLook: "teal-orange",
39
+ atmosphere: ["fog"],
40
+ style: "cinematic",
41
+ mood: ["happy", "joyful"],
42
+ setting: "forest",
43
+ } as const
44
+
45
+ const VIDEO_HINTS = renderDirectionHints(DIRECTION, {
46
+ surface: "video",
47
+ mode: VIDEO_HINT_MODE_DEFAULT,
48
+ })
49
+
50
+ /** The mentioned character — hybrid replaces the mention INLINE, mid-prose. */
51
+ const KIRA: ConnectedReference = {
52
+ id: "kira-id",
53
+ defaultName: "Kira",
54
+ source: "wired-character",
55
+ url: "https://r2.example/kira.png",
56
+ characterSlug: "kira",
57
+ variantSlug: undefined,
58
+ characterCanonicalDescription: "a young woman with copper hair",
59
+ variantDescription: null,
60
+ variantDisplayName: "canonical",
61
+ }
62
+
63
+ /** An UNMENTIONED wired character — hybrid renders its canonical-fallback role
64
+ * phrase at the very END, behind every folded hint. The order-blind casualty. */
65
+ const RAY: ConnectedReference = {
66
+ id: "ray-id",
67
+ defaultName: "Ray",
68
+ source: "wired-character",
69
+ url: "https://r2.example/ray.png",
70
+ characterSlug: "ray",
71
+ variantSlug: undefined,
72
+ characterCanonicalDescription: "a grizzled dockworker",
73
+ variantDescription: null,
74
+ variantDisplayName: "canonical",
75
+ }
76
+
77
+ const KLING_CAP = getMaxVideoPromptChars("kling")
78
+
79
+ /** Prose long enough that prose + bindings + the full fold clears 1000. */
80
+ const PROSE = "@kira:1 walks the seawall at dusk. " + "The waves are loud. ".repeat(14)
81
+
82
+ /**
83
+ * The FRAME under test: the production reference resolver in the hybrid format,
84
+ * exactly the shape the routes' `assembleVideoConnectedReferences` drives it in.
85
+ * Pure, so the composer may call it once per shed iteration.
86
+ */
87
+ const frame = (body: string | undefined): string | undefined =>
88
+ resolveVideoReferenceCore({
89
+ prompt: body,
90
+ wiredCharRefs: [KIRA, RAY],
91
+ hybridRoles: true,
92
+ }).prompt
93
+
94
+ /** The binding that lands inline, inside the prose. */
95
+ const MENTION_BINDING = "@image_1"
96
+ /** Ray is unmentioned → his canonical-fallback phrase is APPENDED, last. */
97
+ const TRAILING_BINDING = "@image_2"
98
+
99
+ describe("composeVideoPromptText — cap-aware hint shedding", () => {
100
+ it("the unshed fold really does overflow kling through the frame (non-vacuity guard)", () => {
101
+ // The oracle for "what the composer produced before": fold every hint, hand
102
+ // the body to the resolver, let the provider clamp decide. If catalog
103
+ // wording ever shrinks enough that this stops overflowing, every scenario
104
+ // below is vacuous and this assertion says so loudly.
105
+ const naive = frame(composeVideoPromptText(PROSE, DIRECTION))!
106
+ expect(naive.length).toBeGreaterThan(KLING_CAP)
107
+ // …and what a tail cut at the cap would destroy is the trailing BINDING,
108
+ // not the decorative tail: the role phrase sits past the cap.
109
+ expect(naive.slice(0, KLING_CAP)).not.toContain(TRAILING_BINDING)
110
+ })
111
+
112
+ it("keeps every binding and the full prose, dropping trailing hints", () => {
113
+ const body = composeVideoPromptText(PROSE, DIRECTION, undefined, {
114
+ cap: KLING_CAP,
115
+ frame,
116
+ })!
117
+ const framed = frame(body)!
118
+
119
+ // Fits — the shed resolved the whole overflow, so the provider clamp is
120
+ // never reached and nothing is cut mid-word.
121
+ expect(framed.length).toBeLessThanOrEqual(KLING_CAP)
122
+
123
+ // Both bindings survive: the mention resolved inline AND the trailing
124
+ // canonical-fallback role phrase the resolver appends last.
125
+ expect(framed).toContain(MENTION_BINDING)
126
+ expect(framed).toContain(TRAILING_BINDING)
127
+
128
+ // The user's prose survives IN FULL and still leads the body (the hint join
129
+ // trims its trailing space), and it survives the framing too — the mention
130
+ // resolving inline to its role phrase is the only edit it takes.
131
+ expect(body.startsWith(PROSE.trim())).toBe(true)
132
+ expect(framed).toContain(PROSE.replace("@kira:1", `the person from ${MENTION_BINDING}`).trim())
133
+
134
+ // The LAST-folded hint clause is gone; the FIRST-folded one stayed. Shedding
135
+ // walks the fold order from the tail, so the dimensions the registry folds
136
+ // first outlive the ones it folds last.
137
+ expect(body).not.toContain(VIDEO_HINTS[VIDEO_HINTS.length - 1])
138
+ expect(body).toContain(VIDEO_HINTS[0])
139
+ })
140
+
141
+ it("sheds more as the cap tightens, and everything at a cap prose alone can't meet", () => {
142
+ const roomy = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: KLING_CAP, frame })!
143
+ const tight = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: 700, frame })!
144
+ expect(tight.length).toBeLessThan(roomy.length)
145
+
146
+ // A cap the framed prose alone cannot meet sheds every hint and then stops —
147
+ // prose is never touched, and the provider clamp stays the last resort.
148
+ const starved = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: 10, frame })
149
+ expect(starved).toBe(PROSE)
150
+ for (const hint of VIDEO_HINTS) expect(starved).not.toContain(hint)
151
+ })
152
+
153
+ it("reserves the caller's budget rather than re-deriving a provider cap", () => {
154
+ // The routes pass `effectiveVideoPromptCeiling`, which for a NON-native
155
+ // negative provider is `cap - "\nAvoid: …".length`. The composer must honor
156
+ // that reduced number verbatim — a composer that read `getMaxVideoPromptChars`
157
+ // itself would shed too little and let the clamp sever the Avoid suffix's
158
+ // room. `minimax` is outside NATIVE_NEGATIVE_VIDEO_PROVIDERS, so its ceiling
159
+ // really does shrink.
160
+ const rawCap = getMaxVideoPromptChars("minimax")
161
+ const reserved = rawCap - "\nAvoid: blurry, low quality".length
162
+ const atRaw = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: rawCap, frame })!
163
+ const atReserved = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: reserved, frame })!
164
+ expect(frame(atReserved)!.length).toBeLessThanOrEqual(reserved)
165
+ expect(atReserved.length).toBeLessThanOrEqual(atRaw.length)
166
+ })
167
+
168
+ it("never sheds the structured fragment — it is user content, not a garnish", () => {
169
+ const structured = { subject: "a lighthouse keeper", action: "hauls a rope hand over hand" }
170
+ const fragment = renderStructuredFields(structured)
171
+ const body = composeVideoPromptText(PROSE, DIRECTION, structured, { cap: 700, frame })!
172
+ expect(body).toContain(fragment)
173
+ // …and it still lands LAST, behind the surviving hints.
174
+ expect(body.endsWith(fragment)).toBe(true)
175
+ })
176
+ })
177
+
178
+ describe("composeVideoPromptText — under-cap byte parity", () => {
179
+ // The oracle is literally the capless call. A framed prompt that FITS must be
180
+ // byte-identical to it, so the whole leg lands dark for every under-cap run.
181
+ const parityCases: ReadonlyArray<{ name: string; provider: string; prompt: string }> = [
182
+ { name: "high-cap provider with the same fold", provider: "seedance-2", prompt: PROSE },
183
+ { name: "low-cap provider, short prose", provider: "kling", prompt: "a knight on a hill" },
184
+ ]
185
+
186
+ for (const { name, provider, prompt } of parityCases) {
187
+ it(`is byte-identical to the capless fold — ${name}`, () => {
188
+ const cap = getMaxVideoPromptChars(provider)
189
+ const expected = composeVideoPromptText(prompt, DIRECTION)
190
+ const actual = composeVideoPromptText(prompt, DIRECTION, undefined, { cap, frame })
191
+ expect(actual).toBe(expected)
192
+ // Guard the guard: a case that overflowed would prove nothing.
193
+ expect(frame(expected)!.length).toBeLessThanOrEqual(cap)
194
+ })
195
+ }
196
+
197
+ it("leaves the no-direction platform-caller path an exact no-op under a cap", () => {
198
+ // No hints → nothing droppable → the prompt comes back verbatim and
199
+ // UNTRIMMED, `undefined` included, exactly as before, even on a tiny cap.
200
+ expect(composeVideoPromptText(" \n", undefined, undefined, { cap: 1, frame })).toBe(" \n")
201
+ expect(composeVideoPromptText(undefined, undefined, undefined, { cap: 1, frame })).toBeUndefined()
202
+ expect(composeVideoPromptText("a knight", {}, undefined, { cap: 1, frame })).toBe("a knight")
203
+ })
204
+
205
+ it("treats an absent frame as identity, and an absent cap as no shedding at all", () => {
206
+ // A caller with a cap but no references measures the body itself…
207
+ const framedless = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: 400 })!
208
+ expect(framedless.length).toBeLessThanOrEqual(400)
209
+ // …and a caller with no cap gets the full fold no matter how long it is.
210
+ expect(composeVideoPromptText(PROSE, DIRECTION, undefined, { frame })).toBe(
211
+ composeVideoPromptText(PROSE, DIRECTION),
212
+ )
213
+ })
214
+ })
215
+
216
+ /**
217
+ * THE SUBJECT CHANNEL UNDER THE SAME CAP. The decision this pins: subject
218
+ * clauses ARE shed candidates, exactly like direction clauses — both are
219
+ * catalog decoration the platform rendered from ids — and they shed AFTER the
220
+ * direction fold, because both channels ride ONE list (`[...subject,
221
+ * ...direction]`) that the shared `hint-shedding.ts` arithmetic walks TAIL
222
+ * FIRST. Exempting the subject fold would not save it: a fully specified person
223
+ * is the largest single fold on the surface, so the overflow would simply land
224
+ * in the provider's order-blind clamp and sever a binding or the prose instead.
225
+ *
226
+ * The image twin of this ordering is pinned in `subject-fold.test.ts`; what is
227
+ * new HERE is the composition the video surface only gained once the cap and
228
+ * the subject channel met — including the SUBJECT-ONLY fold, which neither
229
+ * channel's own suite exercises under a cap.
230
+ *
231
+ * Caps are derived from the framed body rather than hardcoded, so the cases
232
+ * keep testing the claim when catalog wording changes.
233
+ */
234
+ describe("composeVideoPromptText — the subject fold under the cap", () => {
235
+ const SUBJECT = {
236
+ type: "woman",
237
+ ethnicity: "east-asian",
238
+ hairBase: "base-short-straight",
239
+ makeup: "makeup-smoky",
240
+ animal: "dog-corgi",
241
+ } as const
242
+
243
+ const SUBJECT_HINTS = renderSubjectHints(SUBJECT, {
244
+ surface: "video",
245
+ mode: SUBJECT_VIDEO_HINT_MODE_DEFAULT,
246
+ })
247
+
248
+ /**
249
+ * Framed length of the body that folds exactly the first `n` subject clauses
250
+ * and no direction at all — i.e. the tightest budget under which the shed
251
+ * should settle on `n` kept clauses. Built the same way the composer builds
252
+ * it, so the derived caps track catalog wording instead of pinning it.
253
+ */
254
+ const framedWithSubjectClauses = (n: number): number =>
255
+ frame(n === 0 ? PROSE : joinPromptHints(PROSE, SUBJECT_HINTS.slice(0, n)))!.length
256
+
257
+ it("sheds the whole direction fold before a single subject clause", () => {
258
+ // Non-vacuity: subject + direction together really do overflow the frame.
259
+ const unshed = frame(
260
+ composeVideoPromptText(PROSE, DIRECTION, undefined, { subject: SUBJECT }),
261
+ )!
262
+ expect(unshed.length).toBeGreaterThan(KLING_CAP)
263
+ expect(SUBJECT_HINTS.length).toBeGreaterThan(1)
264
+
265
+ // A budget that fits the prose plus the FULL subject fold and nothing else.
266
+ const cap = framedWithSubjectClauses(SUBJECT_HINTS.length)
267
+ const body = composeVideoPromptText(PROSE, DIRECTION, undefined, {
268
+ subject: SUBJECT,
269
+ cap,
270
+ frame,
271
+ })!
272
+ expect(frame(body)!.length).toBeLessThanOrEqual(cap)
273
+ // Every subject clause survived; the direction fold paid the whole bill.
274
+ for (const hint of SUBJECT_HINTS) expect(body).toContain(hint)
275
+ for (const hint of VIDEO_HINTS) expect(body).not.toContain(hint)
276
+ // …and the prose and both bindings are untouched, as always.
277
+ expect(body.startsWith(PROSE.trim())).toBe(true)
278
+ expect(frame(body)!).toContain(MENTION_BINDING)
279
+ expect(frame(body)!).toContain(TRAILING_BINDING)
280
+ })
281
+
282
+ it("then sheds subject clauses too, last-folded first, once direction is gone", () => {
283
+ // Tighter than the prose plus the full subject fold → the tail of the
284
+ // SUBJECT list has to go as well. This is the decision: subject clauses are
285
+ // shed candidates, not a protected channel.
286
+ const cap = framedWithSubjectClauses(SUBJECT_HINTS.length - 1)
287
+ const body = composeVideoPromptText(PROSE, DIRECTION, undefined, {
288
+ subject: SUBJECT,
289
+ cap,
290
+ frame,
291
+ })!
292
+ expect(frame(body)!.length).toBeLessThanOrEqual(cap)
293
+ expect(body).not.toContain(SUBJECT_HINTS[SUBJECT_HINTS.length - 1])
294
+ expect(body).toContain(SUBJECT_HINTS[0])
295
+ })
296
+
297
+ it("sheds a SUBJECT-ONLY fold, and still never the prose or the bindings", () => {
298
+ // The composition neither parent shipped: `subject` with a cap and no
299
+ // `direction` at all — the shape a subject-only route request takes.
300
+ const cap = framedWithSubjectClauses(0)
301
+ const body = composeVideoPromptText(PROSE, undefined, undefined, {
302
+ subject: SUBJECT,
303
+ cap,
304
+ frame,
305
+ })!
306
+ // Everything droppable is gone, so the body is the prose VERBATIM (the
307
+ // `kept === 0` no-op branch), which is what leaves the route's
308
+ // `composed !== prompt` guard correctly unpinned.
309
+ expect(body).toBe(PROSE)
310
+ for (const hint of SUBJECT_HINTS) expect(body).not.toContain(hint)
311
+ const framed = frame(body)!
312
+ expect(framed).toContain(MENTION_BINDING)
313
+ expect(framed).toContain(TRAILING_BINDING)
314
+ })
315
+
316
+ it("never sheds the structured fragment to save a subject clause", () => {
317
+ // Ordering across ALL THREE pieces at once: user content outranks both
318
+ // catalog channels and still lands last.
319
+ const structured = { subject: "a lighthouse keeper", action: "hauls a rope" }
320
+ const fragment = renderStructuredFields(structured)
321
+ const body = composeVideoPromptText(PROSE, DIRECTION, structured, {
322
+ subject: SUBJECT,
323
+ cap: framedWithSubjectClauses(0),
324
+ frame,
325
+ })!
326
+ expect(body.endsWith(fragment)).toBe(true)
327
+ for (const hint of [...SUBJECT_HINTS, ...VIDEO_HINTS]) {
328
+ expect(body).not.toContain(hint)
329
+ }
330
+ })
331
+
332
+ it("is byte-identical to the capless subject fold when it fits", () => {
333
+ // The under-cap parity oracle, extended to the subject channel: a fold that
334
+ // fits must not be able to tell it was budgeted.
335
+ const short = "a knight on a hill"
336
+ const cap = getMaxVideoPromptChars("seedance-2")
337
+ const expected = composeVideoPromptText(short, DIRECTION, undefined, { subject: SUBJECT })
338
+ const actual = composeVideoPromptText(short, DIRECTION, undefined, {
339
+ subject: SUBJECT,
340
+ cap,
341
+ frame,
342
+ })
343
+ expect(actual).toBe(expected)
344
+ expect(frame(expected)!.length).toBeLessThanOrEqual(cap)
345
+ })
346
+
347
+ it("leaves a subject-less request byte-identical under a cap (the parity oracle)", () => {
348
+ // The channel must stay dark: no `subject` → exactly what the direction-only
349
+ // cap path produced before the two met.
350
+ for (const cap of [KLING_CAP, 700, 10]) {
351
+ expect(composeVideoPromptText(PROSE, DIRECTION, undefined, { cap, frame })).toBe(
352
+ composeVideoPromptText(PROSE, DIRECTION, undefined, { subject: {}, cap, frame }),
353
+ )
354
+ }
355
+ })
356
+ })
@@ -0,0 +1,301 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { composeVideoPromptText } from "../assemble-video-input.js"
3
+ import { directionFieldsForSurface } from "../direction-registry.js"
4
+ import { getStylePromptHint, getStyleTerm } from "../style.js"
5
+ import { getTransitionPromptHint, getTransitionTerm } from "../transitions.js"
6
+ import { getCameraMotionPromptHint, getCameraMotionTerm } from "../camera-motions.js"
7
+ import { getFramingPromptHint } from "../framing.js"
8
+ import { getLightingPromptHint } from "../lighting.js"
9
+ import { buildMoodHints } from "../mood.js"
10
+ import { buildAestheticHints } from "../aesthetic.js"
11
+ import { buildAtmosphereHints } from "../atmosphere.js"
12
+ import { buildPhotographerHints } from "../photographer.js"
13
+ import { renderStructuredFields } from "../prompt-builder-structured-fields.js"
14
+
15
+ /**
16
+ * `composeVideoPromptText` is the video route's ONLY prompt-composition step,
17
+ * so two contracts matter here above everything else:
18
+ *
19
+ * 1. THE NO-OP CONTRACT — with no direction the caller's prompt comes back
20
+ * verbatim and untrimmed, `undefined` included. This is the local
21
+ * restatement of the route-level byte-parity oracle ("backward-compatible:
22
+ * no connectedReferences → prompt + flat refs pass through unchanged" in
23
+ * `backend/src/routes/__tests__/generate-video.test.ts`), and it is what
24
+ * makes this whole leg land dark.
25
+ * 2. THE VERBOSITY POLICY — look dimensions render their full clause, motion
26
+ * dimensions their compact professional term. That split moved from the
27
+ * client to the platform, so it is pinned in both directions.
28
+ *
29
+ * Real catalog ids throughout: every `get*PromptHint` returns `""` on a miss,
30
+ * so a made-up id would make most assertions vacuously pass.
31
+ */
32
+
33
+ // ── Real ids, one per dimension used below ──────────────────────────────────
34
+ const STYLE = "cinematic" // look
35
+ const TRANSITION = "cross-dissolve" // motion
36
+ const CAMERA_MOTION = "handheld" // motion
37
+ const SHOT_SIZE = "wide-shot" // look, framing catalog
38
+ const TIME_OF_DAY = "dawn" // look, lighting catalog (time-of-day category)
39
+ const LIGHTING_STYLE = "three-point" // look, lighting catalog (style category)
40
+ const PHOTOGRAPHER = "tim-walker" // IMAGE-ONLY dimension
41
+ const NO_SUCH_ID = "__no_such_id__"
42
+
43
+ describe("composeVideoPromptText — the no-op contract", () => {
44
+ it("returns a prompt verbatim when no direction is passed", () => {
45
+ expect(composeVideoPromptText("a knight rides at dusk", undefined)).toBe(
46
+ "a knight rides at dusk",
47
+ )
48
+ })
49
+
50
+ it("returns a whitespace-only prompt verbatim and UNTRIMMED", () => {
51
+ expect(composeVideoPromptText(" \n", undefined)).toBe(" \n")
52
+ })
53
+
54
+ it("preserves `undefined` (the video prompt is optional)", () => {
55
+ expect(composeVideoPromptText(undefined, undefined)).toBeUndefined()
56
+ })
57
+
58
+ it("treats an empty direction object as no direction", () => {
59
+ expect(composeVideoPromptText("a knight", {})).toBe("a knight")
60
+ expect(composeVideoPromptText(undefined, {})).toBeUndefined()
61
+ })
62
+ })
63
+
64
+ describe("composeVideoPromptText — the verbosity policy", () => {
65
+ it("renders a LOOK dimension as its full clause", () => {
66
+ expect(composeVideoPromptText("a knight", { style: STYLE })).toBe(
67
+ `a knight. ${getStylePromptHint(STYLE)}`,
68
+ )
69
+ })
70
+
71
+ it("renders a MOTION dimension as its compact term, not its full hint", () => {
72
+ const out = composeVideoPromptText("a knight", { transition: TRANSITION })
73
+ expect(out).toBe(`a knight. ${getTransitionTerm(TRANSITION)}`)
74
+ expect(out).not.toContain(getTransitionPromptHint(TRANSITION))
75
+ })
76
+
77
+ it("applies both halves of the split policy in ONE fold", () => {
78
+ const out = composeVideoPromptText("a knight", {
79
+ style: STYLE,
80
+ transition: TRANSITION,
81
+ })
82
+ expect(out).toContain(getStylePromptHint(STYLE))
83
+ expect(out).toContain(getTransitionTerm(TRANSITION))
84
+ expect(out).not.toContain(getTransitionPromptHint(TRANSITION))
85
+ })
86
+
87
+ it("honors a whole-fold `hintMode` override in both directions", () => {
88
+ // "full" promotes the motion family to its full clause…
89
+ expect(
90
+ composeVideoPromptText("a knight", { transition: TRANSITION }, undefined, {
91
+ hintMode: "full",
92
+ }),
93
+ ).toBe(`a knight. ${getTransitionPromptHint(TRANSITION)}`)
94
+ // …and "compact" demotes the look family to its term.
95
+ expect(
96
+ composeVideoPromptText("a knight", { style: STYLE }, undefined, {
97
+ hintMode: "compact",
98
+ }),
99
+ ).toBe(`a knight. ${getStyleTerm(STYLE)}`)
100
+ })
101
+ })
102
+
103
+ describe("composeVideoPromptText — ordering", () => {
104
+ it("puts camera motion first (the order Studio and the orchestrator both emit)", () => {
105
+ const out = composeVideoPromptText("a knight", {
106
+ style: STYLE,
107
+ cameraMotion: CAMERA_MOTION,
108
+ })!
109
+ expect(out.indexOf(getCameraMotionTerm(CAMERA_MOTION))).toBeLessThan(
110
+ out.indexOf(getStylePromptHint(STYLE)),
111
+ )
112
+ // Compact motion again — the camera-motion row is `family: "motion"`.
113
+ expect(out).not.toContain(getCameraMotionPromptHint(CAMERA_MOTION))
114
+ })
115
+
116
+ it("folds in TABLE order, not the caller's object order", () => {
117
+ // `shotSize` (row 2) precedes `style` (row 22) however the object is written.
118
+ const out = composeVideoPromptText("a knight", {
119
+ style: STYLE,
120
+ shotSize: SHOT_SIZE,
121
+ })!
122
+ expect(out.indexOf(getFramingPromptHint(SHOT_SIZE))).toBeLessThan(
123
+ out.indexOf(getStylePromptHint(STYLE)),
124
+ )
125
+ })
126
+
127
+ it("appends the structured fragment AFTER every direction hint", () => {
128
+ const structured = { mood: "wistful" }
129
+ const fragment = renderStructuredFields(structured)
130
+ expect(fragment.length).toBeGreaterThan(0)
131
+ const out = composeVideoPromptText("a knight", { style: STYLE }, structured)!
132
+ expect(out).toBe(`a knight. ${getStylePromptHint(STYLE)}. ${fragment}`)
133
+ })
134
+ })
135
+
136
+ describe("composeVideoPromptText — multi-pick doctrine", () => {
137
+ it("BLENDS two moods into ONE clause (not a per-id loop)", () => {
138
+ const blended = buildMoodHints({ mood: ["happy", "serene"] }, "full")
139
+ expect(blended).toHaveLength(1)
140
+ expect(composeVideoPromptText("a knight", { mood: ["happy", "serene"] })).toBe(
141
+ `a knight. ${blended[0]}`,
142
+ )
143
+ })
144
+
145
+ it("BLENDS two aesthetics into ONE clause", () => {
146
+ const blended = buildAestheticHints(["y2k", "cottagecore"], "full")
147
+ expect(blended.length).toBeGreaterThan(0)
148
+ expect(
149
+ composeVideoPromptText("a knight", { aesthetic: ["y2k", "cottagecore"] }),
150
+ ).toBe(`a knight. ${blended}`)
151
+ })
152
+
153
+ it("slices an over-cap array to the dimension's maxPicks (atmosphere = 2)", () => {
154
+ const out = composeVideoPromptText("a knight", {
155
+ atmosphere: ["clear", "cloudy", "overcast"],
156
+ })!
157
+ const kept = buildAtmosphereHints(["clear", "cloudy"], "full")
158
+ expect(kept).toHaveLength(2)
159
+ expect(out).toBe(`a knight. ${kept.join(". ")}`)
160
+ expect(out).not.toContain(buildAtmosphereHints("overcast", "full")[0])
161
+ })
162
+
163
+ it("accepts an ARRAY on a single-pick key and keeps the first id", () => {
164
+ // The legacy `V2LookPicker` shape: a single-pick dimension that stored an
165
+ // array. Must degrade to one hint, never throw and never drop the key.
166
+ const out = composeVideoPromptText("a knight", { style: [STYLE, "anime"] })
167
+ expect(out).toBe(`a knight. ${getStylePromptHint(STYLE)}`)
168
+ })
169
+ })
170
+
171
+ describe("composeVideoPromptText — tolerance", () => {
172
+ it("skips an unknown id and leaves the prompt verbatim (no dangling '. ')", () => {
173
+ expect(composeVideoPromptText("a knight", { style: NO_SUCH_ID })).toBe("a knight")
174
+ })
175
+
176
+ it("skips an IMAGE-ONLY dimension sent to a video run", () => {
177
+ // `photographer` is accepted on the wire (surface is a render concern, not
178
+ // a wire concern) and simply contributes nothing here.
179
+ expect(buildPhotographerHints(PHOTOGRAPHER, "full").length).toBeGreaterThan(0)
180
+ expect(composeVideoPromptText("a knight", { photographer: PHOTOGRAPHER })).toBe(
181
+ "a knight",
182
+ )
183
+ })
184
+
185
+ it("skips an unknown wire key entirely", () => {
186
+ expect(
187
+ composeVideoPromptText("a knight", { __not_a_dimension__: "x" } as never),
188
+ ).toBe("a knight")
189
+ })
190
+ })
191
+
192
+ describe("composeVideoPromptText — an empty or absent body", () => {
193
+ it("returns the hints alone for an empty prompt (never a leading '. ')", () => {
194
+ expect(composeVideoPromptText("", { style: STYLE })).toBe(getStylePromptHint(STYLE))
195
+ })
196
+
197
+ it("returns the hints alone for an ABSENT prompt", () => {
198
+ expect(composeVideoPromptText(undefined, { style: STYLE })).toBe(
199
+ getStylePromptHint(STYLE),
200
+ )
201
+ })
202
+ })
203
+
204
+ describe("composeVideoPromptText — the dedupe invariant", () => {
205
+ // The five legacy keys address a WHOLE catalog, so they are not aliases of
206
+ // their canonical counterparts. Overlap is resolved by exact-clause dedupe,
207
+ // which suppresses a repeated clause without suppressing a different id.
208
+ it("emits ONE clause when a legacy and a canonical key carry the SAME id", () => {
209
+ expect(
210
+ composeVideoPromptText("a knight", { framingId: SHOT_SIZE, shotSize: SHOT_SIZE }),
211
+ ).toBe(`a knight. ${getFramingPromptHint(SHOT_SIZE)}`)
212
+ })
213
+
214
+ it("emits BOTH clauses for two DIFFERENT ids of one catalog", () => {
215
+ // The case an alias table would have wrongly collapsed: `lightingId` is
216
+ // whole-catalog, so a time-of-day pick beside a lighting-style pick is two
217
+ // legitimate selections.
218
+ const out = composeVideoPromptText("a knight", {
219
+ lightingStyle: LIGHTING_STYLE,
220
+ lightingId: TIME_OF_DAY,
221
+ })
222
+ expect(out).toBe(
223
+ `a knight. ${getLightingPromptHint(LIGHTING_STYLE)}. ${getLightingPromptHint(TIME_OF_DAY)}`,
224
+ )
225
+ })
226
+ })
227
+
228
+ /**
229
+ * ORDER TOTALITY — every video-surface dimension, folded in one call.
230
+ *
231
+ * The fixture is keyed in `directionFieldsForSurface("video")` order and pinned
232
+ * against it, so adding, removing or reordering a video row fails HERE as well
233
+ * as in the registry test. Each id was chosen to render a clause distinct from
234
+ * every other row's, so the dedupe pass cannot mask a mis-ordering.
235
+ */
236
+ const EVERY_VIDEO_DIMENSION: Record<string, string> = {
237
+ cameraMotion: "static",
238
+ shotSize: "extreme-wide-shot",
239
+ angle: "eye-level",
240
+ coverage: "single",
241
+ composition: "rule-of-thirds",
242
+ vantage: "front-on",
243
+ pose: "standing-upright",
244
+ compositionEffect: "bursting-through-frame",
245
+ cameraFormat: "35mm-film",
246
+ lens: "ultra-wide-14mm",
247
+ timeOfDay: "dawn",
248
+ lightingStyle: "three-point",
249
+ lightingDirection: "front",
250
+ lightingRatio: "ratio-1-1",
251
+ colorTemperature: "temp-2700k",
252
+ colorLook: "warm",
253
+ atmosphere: "clear",
254
+ style: "3d-render",
255
+ mood: "happy",
256
+ aesthetic: "y2k",
257
+ setting: "coffee-shop",
258
+ era: "1920s-flapper",
259
+ backdrop: "white-seamless",
260
+ actionFx: "earthquake-tremor",
261
+ temporalSpeed: "real-time",
262
+ temporalFreeze: "full-freeze",
263
+ temporalDirection: "forward",
264
+ temporalShutter: "long-exposure",
265
+ transition: "none",
266
+ loopSubject: "aurora",
267
+ framingId: "wide-shot",
268
+ framingAngleId: "medium-wide-shot",
269
+ lightingId: "sunrise",
270
+ lensId: "wide-24mm",
271
+ cameraFormatId: "16mm-film",
272
+ }
273
+
274
+ describe("composeVideoPromptText — order totality over every video dimension", () => {
275
+ it("covers exactly the video surface, in table order", () => {
276
+ expect(Object.keys(EVERY_VIDEO_DIMENSION)).toEqual(
277
+ directionFieldsForSurface("video").map((f) => f.key),
278
+ )
279
+ })
280
+
281
+ it("resolves every fixture id to a real clause", () => {
282
+ for (const [key, id] of Object.entries(EVERY_VIDEO_DIMENSION)) {
283
+ expect(composeVideoPromptText("", { [key]: id }), `${key}=${id}`).not.toBe("")
284
+ }
285
+ })
286
+
287
+ it("folds all 35 dimensions in registry order, one clause each", () => {
288
+ // Per-dimension renders, composed in isolation through the same public
289
+ // entry point, then concatenated in table order: the whole fold must equal
290
+ // exactly that. Any reorder, drop or duplicate shows up as a diff.
291
+ const expected = Object.entries(EVERY_VIDEO_DIMENSION).map(
292
+ ([key, id]) => composeVideoPromptText("", { [key]: id })!,
293
+ )
294
+ expect(new Set(expected).size, "fixture ids must render distinct clauses").toBe(
295
+ expected.length,
296
+ )
297
+ expect(composeVideoPromptText("a knight", EVERY_VIDEO_DIMENSION)).toBe(
298
+ ["a knight", ...expected].join(". "),
299
+ )
300
+ })
301
+ })