@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.
- package/dist/index.cjs +426 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +830 -51
- package/dist/index.d.ts +830 -51
- package/dist/index.js +410 -37
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-image-input.test.ts +93 -3
- package/src/__tests__/assemble-video-input.test.ts +301 -0
- package/src/__tests__/character-fx-timing-catalogs.test.ts +240 -0
- package/src/__tests__/direction-hint-token-safety.test.ts +113 -0
- package/src/__tests__/direction-registry.test.ts +393 -0
- package/src/__tests__/image-convergence-image.test.ts +370 -0
- package/src/__tests__/read-node-direction.test.ts +154 -0
- package/src/__tests__/transition-timing-catalogs.test.ts +3 -1
- package/src/__tests__/video-reference-ref-id-tokens.test.ts +301 -0
- package/src/assemble-image-input.ts +45 -46
- package/src/assemble-video-input.ts +89 -0
- package/src/character-fx.ts +102 -19
- package/src/direction-registry.ts +354 -0
- package/src/index.ts +8 -2
- package/src/picker-catalogs.ts +18 -1
- package/src/prompt-builder.ts +188 -1
- package/src/prompt-hint-join.ts +30 -0
- package/src/provider-prompt-doctrine.ts +2 -2
- package/src/read-node-direction.ts +174 -0
- package/src/ref-binding.ts +45 -0
- package/src/ref-id-tokens.ts +112 -0
- package/src/video-reference-resolver.ts +78 -39
|
@@ -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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
50
|
-
* expose — all optional.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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
|
|
84
|
-
*
|
|
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.
|
|
146
|
-
*
|
|
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
|
|
150
|
-
* `structured`
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|