@nodaro/prompts 1.9.0 → 1.11.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.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * `{ref:<id>}` / `{ref:<id>:<label>}` — id-addressed reference tokens.
3
+ *
4
+ * A client that names a reference by the `connectedReferences[].id` it sent
5
+ * (Studio's bound `@`-chips) gets the `@image_N` slot substituted by the
6
+ * platform AFTER the platform has numbered the references. That removes the
7
+ * client-side mirror of the numbering walk — the one duplicated rule that could
8
+ * silently misbind pictures for a client built against an older package.
9
+ *
10
+ * Contract pinned here:
11
+ * - the slot comes from the SAME walk that numbers the directives (mention
12
+ * URLs → canonical fallback → extras, offset by the leading flat refs);
13
+ * - the token is resolved BEFORE the `referenceOrder` reorder, so the
14
+ * renumber pass carries the binding to the ref's final seat — the opposite
15
+ * of `{image:N}`, which is resolved AFTER it to keep the author's N;
16
+ * - an unresolvable token never ships raw: label → the ref's display name
17
+ * (when the id is known) → "";
18
+ * - a prompt with no `{ref:` token is byte-identical to before.
19
+ */
20
+ import { describe, it, expect } from "vitest"
21
+ import {
22
+ resolveVideoReferenceCore,
23
+ resolveRefIdTokens,
24
+ resolveReferenceTokens,
25
+ } from "../video-reference-resolver.js"
26
+ import type { ConnectedReference } from "@nodaro/shared"
27
+
28
+ const charRef = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
29
+ id: "char-kira", defaultName: "Kira", source: "wired-character", url: "https://r2/kira.png",
30
+ characterSlug: "kira", variantSlug: undefined, characterCanonicalDescription: null,
31
+ variantDescription: null, variantDisplayName: "canonical", ...over,
32
+ })
33
+
34
+ const A = "https://cdn/a.png"
35
+ const B = "https://cdn/b.png"
36
+
37
+ /** Every case: the token must be gone, whatever it resolved to. */
38
+ function expectNoRawToken(prompt: string | undefined) {
39
+ expect(prompt ?? "").not.toMatch(/\{ref:/i)
40
+ }
41
+
42
+ describe("resolveVideoReferenceCore — {ref:<id>} id-addressed tokens", () => {
43
+ it("bare {ref:<id>} binds an image extra to its @image_N slot", () => {
44
+ const out = resolveVideoReferenceCore({
45
+ prompt: "drive {ref:car-1} fast",
46
+ wiredCharRefs: [],
47
+ extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
48
+ })
49
+ expect(out.additionalUrls).toEqual(["https://r2/car.png"])
50
+ expect(out.prompt).toContain("- @image_1 (reference): a red car.")
51
+ expect(out.prompt).toContain("drive @image_1 fast")
52
+ expectNoRawToken(out.prompt)
53
+ })
54
+
55
+ it("labeled {ref:<id>:<label>} binds through REF_BINDING.image (parity with {image:N:label})", () => {
56
+ const out = resolveVideoReferenceCore({
57
+ prompt: "drive {ref:car-1:car} fast",
58
+ wiredCharRefs: [],
59
+ extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
60
+ })
61
+ expect(out.prompt).toContain("drive the car from @image_1 fast")
62
+ expectNoRawToken(out.prompt)
63
+ })
64
+
65
+ it("a canonical wired character binds to its canonical-fallback slot", () => {
66
+ const out = resolveVideoReferenceCore({
67
+ prompt: "{ref:char-kira} walks in",
68
+ wiredCharRefs: [charRef()],
69
+ })
70
+ expect(out.additionalUrls).toEqual(["https://r2/kira.png"])
71
+ expect(out.prompt).toContain("Use these characters:")
72
+ expect(out.prompt).toContain("@image_1 walks in")
73
+ expectNoRawToken(out.prompt)
74
+ })
75
+
76
+ it("a character VIEW (extra with characterSlug) binds to its pair-back slot after the canonical", () => {
77
+ const out = resolveVideoReferenceCore({
78
+ prompt: "{ref:view-1} turns to face {ref:char-kira}",
79
+ wiredCharRefs: [charRef()],
80
+ extraRefs: [{ id: "view-1", url: "https://r2/kira-side.png", characterSlug: "kira", description: "side profile" }],
81
+ })
82
+ expect(out.additionalUrls).toEqual(["https://r2/kira.png", "https://r2/kira-side.png"])
83
+ expect(out.prompt).toContain("- @image_2 is the same subject as @image_1, side profile.")
84
+ expect(out.prompt).toContain("@image_2 turns to face @image_1")
85
+ expectNoRawToken(out.prompt)
86
+ })
87
+
88
+ it("leading flat refs offset the slot (D5 image-refs-first)", () => {
89
+ const out = resolveVideoReferenceCore({
90
+ prompt: "the {ref:obj} on the table",
91
+ wiredCharRefs: [],
92
+ leadingRefUrls: [A],
93
+ extraRefs: [{ id: "obj", url: B, description: "object" }],
94
+ })
95
+ expect(out.additionalUrls).toEqual([A, B])
96
+ expect(out.prompt).toContain("the @image_2 on the table")
97
+ expectNoRawToken(out.prompt)
98
+ })
99
+
100
+ it("ids are opaque: `:` and `/` inside an id resolve, and a trailing label still parses", () => {
101
+ const out = resolveVideoReferenceCore({
102
+ prompt: "{ref:https://cdn/pic.png} beside {ref:kira:smile:smile}",
103
+ wiredCharRefs: [],
104
+ extraRefs: [
105
+ { id: "https://cdn/pic.png", url: "https://cdn/pic.png", description: "pic" },
106
+ { id: "kira:smile", url: "https://r2/kira-smile.png", description: "smile" },
107
+ ],
108
+ })
109
+ expect(out.prompt).toContain("@image_1 beside the smile from @image_2")
110
+ expectNoRawToken(out.prompt)
111
+ })
112
+
113
+ it("an unknown id degrades to its label, or to nothing — never the raw token", () => {
114
+ const out = resolveVideoReferenceCore({
115
+ prompt: "a {ref:nope:ghost} b {ref:nope2} c",
116
+ wiredCharRefs: [],
117
+ extraRefs: [{ id: "x", url: A, description: "d" }],
118
+ })
119
+ expect(out.prompt).toContain("a ghost b c")
120
+ expectNoRawToken(out.prompt)
121
+ })
122
+
123
+ it("a known ref the walk never seated degrades to its display name", () => {
124
+ // A wired character with no url is skipped by the canonical loop — the id
125
+ // is known (so the name is), but there is no slot to bind.
126
+ const out = resolveVideoReferenceCore({
127
+ prompt: "{ref:char-kira} waves at {ref:capped-1}",
128
+ wiredCharRefs: [charRef({ url: "" })],
129
+ extraRefs: [{ id: "x", url: A, description: "d" }],
130
+ // The caller's full id → name map (the route builds it from EVERY
131
+ // connectedReference, including the ones it capped out before the walk).
132
+ refNamesById: new Map([["capped-1", "Truck"]]),
133
+ })
134
+ expect(out.prompt).toContain("Kira waves at Truck")
135
+ expectNoRawToken(out.prompt)
136
+ })
137
+
138
+ it("a duplicate-URL extra never binds past the payload: its {ref:} degrades to its name", () => {
139
+ // The walk counts every extra with a url while `merged` dedups by URL, so
140
+ // the second extra's directive is numbered @image_2 although the payload
141
+ // carries ONE image (pre-existing walk-vs-merged drift). The token is
142
+ // range-gated against the image count, so it degrades instead of emitting
143
+ // a phantom binding.
144
+ const out = resolveVideoReferenceCore({
145
+ prompt: "{ref:x} then {ref:y}",
146
+ wiredCharRefs: [],
147
+ extraRefs: [
148
+ { id: "x", url: A, description: "first" },
149
+ { id: "y", url: A, description: "second" },
150
+ ],
151
+ refNamesById: new Map([["y", "Second"]]),
152
+ })
153
+ expect(out.additionalUrls).toEqual([A])
154
+ expect(out.prompt).toContain("@image_1 then Second")
155
+ expectNoRawToken(out.prompt)
156
+ })
157
+
158
+ it("resolves BEFORE the referenceOrder reorder, so the binding follows the ref to its final seat", () => {
159
+ const out = resolveVideoReferenceCore({
160
+ prompt: "{ref:y} leads, {ref:x} follows",
161
+ wiredCharRefs: [],
162
+ extraRefs: [
163
+ { id: "x", url: A, description: "ax" },
164
+ { id: "y", url: B, description: "by" },
165
+ ],
166
+ // Extras' tile ids are `wired:<url>` (the reorder contract, unchanged).
167
+ referenceOrder: [`wired:${B}`, `wired:${A}`],
168
+ })
169
+ expect(out.additionalUrls).toEqual([B, A])
170
+ expect(out.prompt).toContain("@image_1 leads, @image_2 follows")
171
+ expect(out.prompt).toContain("- @image_1 (reference): by.")
172
+ expect(out.prompt).toContain("- @image_2 (reference): ax.")
173
+ expectNoRawToken(out.prompt)
174
+ })
175
+
176
+ it("keeps {image:N} resolved AFTER the reorder (author's N kept) while {ref:} follows the ref", () => {
177
+ const out = resolveVideoReferenceCore({
178
+ prompt: "{ref:y} and {image:2:second}",
179
+ wiredCharRefs: [],
180
+ extraRefs: [
181
+ { id: "x", url: A, description: "ax" },
182
+ { id: "y", url: B, description: "by" },
183
+ ],
184
+ referenceOrder: [`wired:${B}`, `wired:${A}`],
185
+ })
186
+ // y moved to seat 1 → {ref:y} rides along; {image:2} keeps the literal 2.
187
+ expect(out.prompt).toContain("@image_1 and the second from @image_2")
188
+ expectNoRawToken(out.prompt)
189
+ })
190
+
191
+ it("an @-mentioned character's ref binds to the mention's slot", () => {
192
+ const out = resolveVideoReferenceCore({
193
+ prompt: "@kira:1 waves, then {ref:char-kira} sits",
194
+ wiredCharRefs: [charRef()],
195
+ })
196
+ expect(out.additionalUrls).toEqual(["https://r2/kira.png"])
197
+ expect(out.prompt).toContain("Kira waves, then @image_1 sits")
198
+ expectNoRawToken(out.prompt)
199
+ })
200
+
201
+ it("is independent of hybridRoles — same slot, no legend block", () => {
202
+ const out = resolveVideoReferenceCore({
203
+ prompt: "drive {ref:car-1} fast",
204
+ wiredCharRefs: [],
205
+ extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
206
+ hybridRoles: true,
207
+ })
208
+ expect(out.prompt).not.toContain("Use these characters:")
209
+ expect(out.prompt).toContain("drive @image_1 fast")
210
+ expectNoRawToken(out.prompt)
211
+ })
212
+
213
+ it("a prompt with no {ref: token is untouched — `{ref}` and `ref:` are not tokens", () => {
214
+ const out = resolveVideoReferenceCore({
215
+ prompt: "circle {image:1:object} {ref} ref: x",
216
+ wiredCharRefs: [],
217
+ extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
218
+ })
219
+ expect(out.prompt).toContain("circle the object from @image_1 {ref} ref: x")
220
+ })
221
+
222
+ it("an empty id drops to nothing and the keyword is case-insensitive", () => {
223
+ const out = resolveVideoReferenceCore({
224
+ prompt: "x {ref:} {REF:car-1} y",
225
+ wiredCharRefs: [],
226
+ extraRefs: [{ id: "car-1", url: "https://r2/car.png", description: "a red car" }],
227
+ })
228
+ expect(out.prompt).toContain("x @image_1 y")
229
+ expectNoRawToken(out.prompt)
230
+ })
231
+
232
+ it("imageRefCount: 0 (no image tokens may bind) degrades a seated ref to its name", () => {
233
+ const out = resolveVideoReferenceCore({
234
+ prompt: "{ref:char-kira} walks",
235
+ wiredCharRefs: [charRef()],
236
+ imageRefCount: 0,
237
+ })
238
+ expect(out.prompt).toContain("Kira walks")
239
+ expect(out.prompt).not.toContain("@image_1 walks")
240
+ expectNoRawToken(out.prompt)
241
+ })
242
+
243
+ it("early-return path (no wired chars, no extras): degrades to name / label / nothing", () => {
244
+ const out = resolveVideoReferenceCore({
245
+ prompt: "{ref:a} and {ref:b:the dog} and {ref:c}",
246
+ wiredCharRefs: [],
247
+ leadingRefUrls: [A],
248
+ refNamesById: new Map([["a", "Alpha"]]),
249
+ })
250
+ expect(out.additionalUrls).toEqual([A])
251
+ expect(out.prompt).toBe("Alpha and the dog and")
252
+ expectNoRawToken(out.prompt)
253
+ })
254
+ })
255
+
256
+ describe("resolveRefIdTokens — malformed and adversarial input", () => {
257
+ it("a malformed token (brace inside the id, no closing brace) never ships its `{ref:` prefix", () => {
258
+ const out = resolveRefIdTokens("x {ref:a{b} y {ref:unclosed z", {
259
+ slotById: new Map([["a", 1]]),
260
+ nameById: new Map(),
261
+ imageCount: 1,
262
+ })
263
+ expect(out).not.toMatch(/\{ref:/i)
264
+ // The net is bounded by whitespace/braces: the prose after each run survives.
265
+ expect(out).toContain(" y ")
266
+ expect(out).toContain(" z")
267
+ })
268
+
269
+ it("scans a prompt at the hard ceiling with an adversarial shape and still resolves (linear matcher)", () => {
270
+ // 30k chars of `{ref:` followed by label-class text with no closing brace —
271
+ // the shape that made a lazy-quantifier matcher quadratic.
272
+ const adversarial = "{ref:" + ":a".repeat(15000)
273
+ const out = resolveRefIdTokens(`${adversarial} end {ref:x}`, {
274
+ slotById: new Map([["x", 1]]),
275
+ nameById: new Map(),
276
+ imageCount: 1,
277
+ })
278
+ expect(out).not.toMatch(/\{ref:/i)
279
+ expect(out).toContain("end @image_1")
280
+ })
281
+ })
282
+
283
+ describe("resolveRefIdTokens (standalone — the route's no-image-ref early return)", () => {
284
+ it("binds in-range slots, and degrades label → name → nothing otherwise", () => {
285
+ const resolved = resolveRefIdTokens("{ref:x:car} {ref:x} {ref:y:dog} {ref:y} {ref:z}", {
286
+ slotById: new Map([["x", 2], ["y", 4]]),
287
+ nameById: new Map([["y", "Dog"]]),
288
+ imageCount: 3,
289
+ })
290
+ // y is seated at 4 but only 3 images ship → name; z is unknown → nothing.
291
+ expect(resolveReferenceTokens(resolved, { image: 3, video: 0, audio: 0 })).toBe(
292
+ "the car from @image_2 @image_2 dog Dog",
293
+ )
294
+ })
295
+
296
+ it("returns the prompt untouched when no {ref: token is present", () => {
297
+ const prompt = "plain {image:1} prose"
298
+ expect(resolveRefIdTokens(prompt, { slotById: new Map(), nameById: new Map(), imageCount: 0 })).toBe(prompt)
299
+ expect(resolveRefIdTokens(undefined, { slotById: new Map(), nameById: new Map(), imageCount: 0 })).toBeUndefined()
300
+ })
301
+ })
@@ -16,13 +16,17 @@
16
16
  * This wrapper collapses them into one.
17
17
  *
18
18
  * THE NO-OP CONTRACT (load-bearing — the platform-caller parity relies on it):
19
- * the two platform callers (`execute-node` / `payload-builder`) compose their
20
- * prompt from the canvas graph themselves and pass NO cinematic `direction`
21
- * ids and NO `structured` fields. In that case `composePromptText` MUST return
22
- * the caller's `userPrompt` byte-for-byte unchanged, so the wrapper degenerates
23
- * to exactly the `buildImagePrompt(...)` call those sites make today. Studio
24
- * (and the MCP route) supply `direction` / `structured` and get the id-hint
25
- * composition on top.
19
+ * a node that carries NO stored `direction` / `structured` (every workflow
20
+ * authored before the canvas honored them) still reaches here with both absent,
21
+ * and `composePromptText` MUST return the caller's `userPrompt` byte-for-byte
22
+ * unchanged, so the wrapper degenerates to exactly the `buildImagePrompt(...)`
23
+ * call those sites made before. The platform callers (`execute-node` /
24
+ * `payload-builder`) compose their prompt from the canvas GRAPH themselves and
25
+ * ALSO forward a node's STORED `direction` / `structured` when it carries them
26
+ * (`readDirectionFields` / `readStructuredFields`); those nodes get the id-hint
27
+ * composition on top, ADDITIVE to the graph-wired cinematography hints the
28
+ * caller already folded into `userPrompt`. Studio and the MCP route supply the
29
+ * same two levers directly.
26
30
  *
27
31
  * THE EMPTY-CHECK FLAG (also load-bearing for parity): `execute-node` rejects a
28
32
  * truly-empty assembled prompt (its "type one, mention a character, or connect
@@ -35,31 +39,28 @@ import {
35
39
  buildImagePrompt,
36
40
  type BuildImagePromptResult,
37
41
  } from "./prompt-builder.js"
38
- import { getFramingPromptHint } from "./framing.js"
39
- import { getLightingPromptHint } from "./lighting.js"
40
- import { getLensPromptHint } from "./lens.js"
41
- import { getCameraFormatPromptHint } from "./camera-format.js"
42
42
  import {
43
43
  renderStructuredFields,
44
44
  type StructuredPromptFields,
45
45
  } from "./prompt-builder-structured-fields.js"
46
+ import {
47
+ renderDirectionHints,
48
+ IMAGE_HINT_MODE_DEFAULT,
49
+ type DirectionFields,
50
+ } from "./direction-registry.js"
51
+ import { joinPromptHints } from "./prompt-hint-join.js"
46
52
  import type { CharacterDef, ConnectedReference, IdentityMeta } from "@nodaro/shared"
47
53
 
48
54
  /**
49
- * Flat cinematic-direction ids the Studio framing UI (and the MCP route)
50
- * expose — all optional. Promoted here from Studio's `assembly.ts` so the
51
- * id → hint composition lives in one place. The platform callers pass none of
52
- * these (they fold their hints from the graph into `userPrompt` themselves).
55
+ * Flat cinematic-direction ids the Studio framing UI, the MCP route and the
56
+ * canvas node data expose — all optional. The dimensions, their canonical fold
57
+ * ORDER and their per-catalog rendering live in `direction-registry.ts`; this
58
+ * re-export keeps the import path stable for existing consumers. The platform
59
+ * callers fold their GRAPH-WIRED hints into `userPrompt` themselves and pass
60
+ * these only when the node carries them as stored data (Studio-emitted graphs,
61
+ * spec D3).
53
62
  */
54
- export interface DirectionFields {
55
- /** Shot Type — the FRAMINGS shot-size/coverage/composition/vantage dimensions. */
56
- framingId?: string
57
- /** Angle — the FRAMINGS angle dimension (separate pill, so it can coexist with Shot Type). */
58
- framingAngleId?: string
59
- lightingId?: string
60
- lensId?: string
61
- cameraFormatId?: string
62
- }
63
+ export type { DirectionFields }
63
64
 
64
65
  /**
65
66
  * Input to `assembleImageInput`. A faithful SUPERSET of what the two platform
@@ -80,8 +81,9 @@ export interface AssembleImageInput {
80
81
  connectedReferences?: ConnectedReference[]
81
82
  /**
82
83
  * Flat cinematic-direction ids → folded into the prompt as hints. Studio /
83
- * MCP-route use; the platform callers pass none (so `composePromptText` is a
84
- * no-op for them and the result is byte-identical to today).
84
+ * MCP-route use, and the platform callers' narrow-read of a node's STORED
85
+ * `data.direction`; absent on a node that carries none (so `composePromptText`
86
+ * is a no-op for it and the result is byte-identical to today).
85
87
  */
86
88
  direction?: DirectionFields
87
89
  /** Path-1 structured fields → composed fragment appended to the prompt. */
@@ -142,17 +144,23 @@ export interface AssembleImageInput {
142
144
 
143
145
  /**
144
146
  * Compose the cinematic-direction hints + structured-field fragment with the
145
- * user's prompt. Each `get*PromptHint` returns "" on a miss, and
146
- * `renderStructuredFields` returns "" when nothing is populated.
147
+ * user's prompt. `renderDirectionHints` folds the `direction` ids in the
148
+ * registry's canonical table order (unknown keys and unknown ids contribute
149
+ * nothing), and `renderStructuredFields` returns "" when nothing is populated —
150
+ * so the structured fragment always lands LAST.
147
151
  *
148
152
  * EXACT NO-OP CONTRACT: when there are no cinematic/structured hint pieces (the
149
- * platform-caller case — execute-node / payload-builder never pass `direction`/
150
- * `structured`), the user's prompt is returned **verbatim, untrimmed**. This is
151
- * load-bearing for parity: the old platform path passed the prompt straight to
152
- * `buildImagePrompt`, which never trims, so trimming here would change the
153
- * assembled prompt (and the recorded `jobs.input_data`) byte-for-byte. We only
154
- * trim the user prompt when joining it WITH hints, so it reads cleanly
155
- * ("prompt. hint", not "prompt . hint"). Never mutates inputs.
153
+ * platform-caller case for a node that carries no stored `direction`/
154
+ * `structured` — every workflow authored before the canvas honored them), the
155
+ * user's prompt is returned **verbatim, untrimmed** by `joinPromptHints`. This
156
+ * is load-bearing for parity: the old platform path passed the prompt straight
157
+ * to `buildImagePrompt`, which never trims, so trimming here would change the
158
+ * assembled prompt (and the recorded `jobs.input_data`) byte-for-byte. Never
159
+ * mutates inputs.
160
+ *
161
+ * A node that DOES carry `direction`/`structured` takes the join branch and is
162
+ * therefore trimmed + `". "`-joined — intended, and asserted at the caller
163
+ * level by the payload-builder before/after test.
156
164
  */
157
165
  function composePromptText(
158
166
  userPrompt: string,
@@ -160,19 +168,10 @@ function composePromptText(
160
168
  structured: StructuredPromptFields | undefined,
161
169
  ): string {
162
170
  const hints = [
163
- getFramingPromptHint(direction?.framingId),
164
- getFramingPromptHint(direction?.framingAngleId),
165
- getLightingPromptHint(direction?.lightingId),
166
- getLensPromptHint(direction?.lensId),
167
- getCameraFormatPromptHint(direction?.cameraFormatId),
171
+ ...renderDirectionHints(direction, { surface: "image", mode: IMAGE_HINT_MODE_DEFAULT }),
168
172
  structured ? renderStructuredFields(structured) : "",
169
173
  ].filter((p) => p.length > 0)
170
- // No hints → verbatim (exact no-op = platform parity). With hints → trim the
171
- // user prompt so the ". " join is clean. The trailing filter drops a blank
172
- // user prompt so the join never starts with ". " (parity-critical — don't
173
- // remove it as "redundant": `hints` is pre-filtered but `userPrompt` is not).
174
- if (hints.length === 0) return userPrompt
175
- return [userPrompt.trim(), ...hints].filter((p) => p.length > 0).join(". ")
174
+ return joinPromptHints(userPrompt, hints)
176
175
  }
177
176
 
178
177
  /**
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `composeVideoPromptText` — the video twin of `assemble-image-input.ts`'s
3
+ * `composePromptText`: fold cinematic-direction picker IDS into the prompt BODY,
4
+ * server-side, at the model call.
5
+ *
6
+ * WHY THIS EXISTS: `/v1/generate-video` had no structured direction channel, so
7
+ * every client baked the hint TEXT itself. A copied scene then carried stale
8
+ * catalog wording forever, a re-generate double-baked it, and each client
9
+ * re-implemented the fold with its own separator and its own order. The wire
10
+ * now carries ids; the platform renders the clauses.
11
+ *
12
+ * WHERE IT RUNS (load-bearing): on the prompt BODY, BEFORE
13
+ * `resolveVideoReferenceCore`. That resolver FRAMES the body — legacy prepends
14
+ * its `Use these characters:` block, hybrid prepends the lock lines and APPENDS
15
+ * the canonical role phrases and extras. Folding afterwards would push the
16
+ * scene/look description PAST the identity directives, a worse version of the
17
+ * bug this channel exists to fix. The image side is structurally identical
18
+ * (`assembleImageInput` = `composePromptText` → `buildImagePrompt`).
19
+ *
20
+ * THE VERBOSITY POLICY LIVES HERE, NOT IN THE CLIENT: motion dimensions render
21
+ * their compact professional term, look dimensions their full clause
22
+ * (`VIDEO_HINT_MODE_DEFAULT`, resolved per row's `family` by the registry).
23
+ * It is a threaded PARAMETER with a pure default — never deployment state:
24
+ * `__tests__/content-free-contract.test.ts` hard-fails any environment read
25
+ * under `packages/prompts/src`, and this module has nothing to read anyway.
26
+ *
27
+ * EXACT NO-OP CONTRACT: with no direction and no structured fields the caller's
28
+ * `userPrompt` comes back VERBATIM AND UNTRIMMED — `undefined` included, since
29
+ * a video prompt is optional on the route. That is what keeps every existing
30
+ * caller byte-identical (the "backward-compatible: no connectedReferences →
31
+ * prompt + flat refs pass through unchanged" oracle in
32
+ * `backend/src/routes/__tests__/generate-video.test.ts`, restated locally in
33
+ * `__tests__/assemble-video-input.test.ts`).
34
+ *
35
+ * WHAT IS DELIBERATELY NOT HERE: the dimension table, the fold order, the
36
+ * dedupe and the surface filter all live in `direction-registry.ts` — ONE
37
+ * renderer serves both surfaces, so the image and video folds cannot drift.
38
+ * Clients render their "will inject into prompt" preview by importing
39
+ * `renderDirectionHints` + `joinPromptHints` directly.
40
+ */
41
+ import {
42
+ renderDirectionHints,
43
+ VIDEO_HINT_MODE_DEFAULT,
44
+ type DirectionFields,
45
+ type DirectionHintMode,
46
+ } from "./direction-registry.js"
47
+ import { joinPromptHints } from "./prompt-hint-join.js"
48
+ import {
49
+ renderStructuredFields,
50
+ type StructuredPromptFields,
51
+ } from "./prompt-builder-structured-fields.js"
52
+
53
+ /**
54
+ * Fold a video run's cinematic-direction ids (and optional structured fields)
55
+ * into its prompt body.
56
+ *
57
+ * The direction hints land first, in the registry's canonical table order
58
+ * (camera motion leads), and the structured fragment lands LAST — the same
59
+ * ordering `composePromptText` uses for stills.
60
+ *
61
+ * @param userPrompt The user's prompt. Optional: an image-to-video run may
62
+ * legitimately have none, and it is returned as-is when nothing folds.
63
+ * @param direction Flat catalog ids. Unknown keys, off-surface keys (an
64
+ * image-only dimension sent to a video run) and unknown ids all contribute
65
+ * nothing — never a throw.
66
+ * @param structured Path-1 structured fields. Not a `/v1/generate-video` wire
67
+ * field today; the canvas orchestrator passes it directly.
68
+ * @param opts.hintMode Override the verbosity policy (a whole-fold
69
+ * `PickerHintMode`, or a `{ look, motion }` split).
70
+ */
71
+ export function composeVideoPromptText(
72
+ userPrompt: string | undefined,
73
+ direction: DirectionFields | undefined,
74
+ structured?: StructuredPromptFields,
75
+ opts?: { readonly hintMode?: DirectionHintMode },
76
+ ): string | undefined {
77
+ const hints = [
78
+ ...renderDirectionHints(direction, {
79
+ surface: "video",
80
+ mode: opts?.hintMode ?? VIDEO_HINT_MODE_DEFAULT,
81
+ }),
82
+ structured ? renderStructuredFields(structured) : "",
83
+ ].filter((p) => p.length > 0)
84
+ // Nothing to fold → the caller's value straight back, `undefined` included.
85
+ // Do NOT collapse this into `joinPromptHints(userPrompt ?? "", hints)`: that
86
+ // would turn an absent prompt into `""` and break the no-op contract above.
87
+ if (hints.length === 0) return userPrompt
88
+ return joinPromptHints(userPrompt ?? "", hints)
89
+ }