@nodaro/prompts 1.10.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 +318 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +594 -25
- package/dist/index.d.ts +594 -25
- package/dist/index.js +306 -14
- 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__/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/assemble-image-input.ts +45 -46
- package/src/assemble-video-input.ts +89 -0
- package/src/direction-registry.ts +354 -0
- package/src/index.ts +8 -2
- package/src/prompt-builder.ts +188 -1
- package/src/prompt-hint-join.ts +30 -0
- package/src/read-node-direction.ts +174 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { buildImagePrompt } from "../prompt-builder.js"
|
|
3
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* P3 — named-image mentions `@<name-slug>:<index>[:<role>]`.
|
|
7
|
+
*
|
|
8
|
+
* The image analog of `location-convergence-image.test.ts`. `referenceFormat`
|
|
9
|
+
* is passed EXPLICITLY on every hybrid case: `packages/prompts` is env-free
|
|
10
|
+
* (`content-free-contract.test.ts`), so `NODE_ENV=test` only steers the
|
|
11
|
+
* *callers*, never this package.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const town: ConnectedReference = {
|
|
15
|
+
id: "n1", defaultName: "Town", source: "wired-image", url: "https://cdn/town.png",
|
|
16
|
+
}
|
|
17
|
+
const plaza: ConnectedReference = {
|
|
18
|
+
id: "n2", defaultName: "Plaza", source: "wired-image", url: "https://cdn/plaza.png",
|
|
19
|
+
}
|
|
20
|
+
const kira: ConnectedReference = {
|
|
21
|
+
id: "c1", defaultName: "Kira", source: "wired-character",
|
|
22
|
+
url: "https://cdn/kira.png", characterSlug: "kira",
|
|
23
|
+
}
|
|
24
|
+
const library: ConnectedReference = {
|
|
25
|
+
id: "l1", defaultName: "Old Library", source: "wired-location",
|
|
26
|
+
url: "https://cdn/library.png", locationSlug: "old-library",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("named-image mentions resolve on the image hybrid path", () => {
|
|
30
|
+
it("bare @town:3 → the reference's bare binding, token consumed, URL attached once", () => {
|
|
31
|
+
const out = buildImagePrompt({
|
|
32
|
+
prompt: "a wide shot of @town:3 at dusk",
|
|
33
|
+
connectedReferences: [town],
|
|
34
|
+
provider: "nano-banana-pro",
|
|
35
|
+
referenceFormat: "hybrid",
|
|
36
|
+
})
|
|
37
|
+
// `wired-image`'s default role is "" (DEFAULT_LABEL_BY_SOURCE) →
|
|
38
|
+
// `roleToPhrase("", binding)` returns the BARE binding.
|
|
39
|
+
expect(out.prompt).toContain("reference image A")
|
|
40
|
+
expect(out.prompt).not.toContain("@town")
|
|
41
|
+
expect(out.referenceImageUrls).toEqual(["https://cdn/town.png"])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("@town:3:background → 'the background from reference image A'", () => {
|
|
45
|
+
const out = buildImagePrompt({
|
|
46
|
+
prompt: "a wide shot of @town:3:background at dusk",
|
|
47
|
+
connectedReferences: [town],
|
|
48
|
+
provider: "nano-banana-pro",
|
|
49
|
+
referenceFormat: "hybrid",
|
|
50
|
+
})
|
|
51
|
+
expect(out.prompt).toContain("the background from reference image A")
|
|
52
|
+
expect(out.prompt).not.toContain("@town")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("a CUSTOM role passes through verbatim → 'the signage from reference image A'", () => {
|
|
56
|
+
const out = buildImagePrompt({
|
|
57
|
+
prompt: "a wide shot of @town:3:signage at dusk",
|
|
58
|
+
connectedReferences: [town],
|
|
59
|
+
provider: "nano-banana-pro",
|
|
60
|
+
referenceFormat: "hybrid",
|
|
61
|
+
})
|
|
62
|
+
expect(out.prompt).toContain("the signage from reference image A")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("the node's own defaultRole is the fallback when the token carries none", () => {
|
|
66
|
+
const out = buildImagePrompt({
|
|
67
|
+
prompt: "a wide shot of @town:3 at dusk",
|
|
68
|
+
connectedReferences: [{ ...town, defaultRole: "texture" } as ConnectedReference],
|
|
69
|
+
provider: "nano-banana-pro",
|
|
70
|
+
referenceFormat: "hybrid",
|
|
71
|
+
})
|
|
72
|
+
expect(out.prompt).toContain("the texture from reference image A")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("mixed character + location + image: A = character, B = location, C = image", () => {
|
|
76
|
+
const out = buildImagePrompt({
|
|
77
|
+
prompt: "@kira:1 walks past @old-library:2 toward @town:3:background",
|
|
78
|
+
connectedReferences: [kira, library, town],
|
|
79
|
+
provider: "nano-banana-pro",
|
|
80
|
+
referenceFormat: "hybrid",
|
|
81
|
+
})
|
|
82
|
+
// Mention URLs merge in pass order: characters, then locations, then images.
|
|
83
|
+
expect(out.referenceImageUrls).toEqual([
|
|
84
|
+
"https://cdn/kira.png",
|
|
85
|
+
"https://cdn/library.png",
|
|
86
|
+
"https://cdn/town.png",
|
|
87
|
+
])
|
|
88
|
+
// The letters are the assertion; the location pass's own default role
|
|
89
|
+
// wording belongs to `location-convergence-image.test.ts`, not here.
|
|
90
|
+
expect(out.prompt).toContain("from reference image A")
|
|
91
|
+
expect(out.prompt).toContain("from reference image B")
|
|
92
|
+
expect(out.prompt).toContain("the background from reference image C")
|
|
93
|
+
expect(out.prompt).not.toContain("@town")
|
|
94
|
+
expect(out.prompt).not.toContain("@kira")
|
|
95
|
+
expect(out.prompt).not.toContain("@old-library")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("PRECEDENCE: a character and an image sharing a name resolve as the CHARACTER", () => {
|
|
99
|
+
const townCharacter: ConnectedReference = {
|
|
100
|
+
id: "c2", defaultName: "Town", source: "wired-character",
|
|
101
|
+
url: "https://cdn/town-character.png", characterSlug: "town",
|
|
102
|
+
}
|
|
103
|
+
const out = buildImagePrompt({
|
|
104
|
+
prompt: "@town:1 stands in the square",
|
|
105
|
+
connectedReferences: [townCharacter, town],
|
|
106
|
+
provider: "nano-banana-pro",
|
|
107
|
+
referenceFormat: "hybrid",
|
|
108
|
+
})
|
|
109
|
+
// The character pass splices the token out first, so the image pass finds
|
|
110
|
+
// nothing to bind and the image ref keeps its plain auto-attach slot.
|
|
111
|
+
expect(out.prompt).toContain("the person from reference image A")
|
|
112
|
+
expect(out.referenceImageUrls?.[0]).toBe("https://cdn/town-character.png")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("DUPLICATE SLUGS bind FIRST-WINS", () => {
|
|
116
|
+
const first: ConnectedReference = {
|
|
117
|
+
id: "u1", defaultName: "Upload Image", source: "wired-image", url: "https://cdn/first.png",
|
|
118
|
+
}
|
|
119
|
+
const second: ConnectedReference = {
|
|
120
|
+
id: "u2", defaultName: "Upload Image", source: "wired-image", url: "https://cdn/second.png",
|
|
121
|
+
}
|
|
122
|
+
const out = buildImagePrompt({
|
|
123
|
+
prompt: "a shot of @upload-image:1",
|
|
124
|
+
connectedReferences: [first, second],
|
|
125
|
+
provider: "nano-banana-pro",
|
|
126
|
+
referenceFormat: "hybrid",
|
|
127
|
+
})
|
|
128
|
+
// The mention re-seats the FIRST ref, so it takes slot A; the unmentioned
|
|
129
|
+
// second ref still auto-attaches after it.
|
|
130
|
+
expect(out.referenceImageUrls).toEqual(["https://cdn/first.png", "https://cdn/second.png"])
|
|
131
|
+
expect(out.prompt).toContain("reference image A")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("~lock forces an identity-lock line; ~nolock suppresses a ref-level one", () => {
|
|
135
|
+
const locked = buildImagePrompt({
|
|
136
|
+
prompt: "a shot of @town:1~lock",
|
|
137
|
+
connectedReferences: [{
|
|
138
|
+
...town,
|
|
139
|
+
identityLock: { enabled: false, text: "Keep {ref} pixel-exact." },
|
|
140
|
+
} as ConnectedReference],
|
|
141
|
+
provider: "nano-banana-pro",
|
|
142
|
+
referenceFormat: "hybrid",
|
|
143
|
+
})
|
|
144
|
+
expect(locked.prompt).toContain("Keep reference image A pixel-exact.")
|
|
145
|
+
|
|
146
|
+
const unlocked = buildImagePrompt({
|
|
147
|
+
prompt: "a shot of @town:1~nolock",
|
|
148
|
+
connectedReferences: [{
|
|
149
|
+
...town,
|
|
150
|
+
identityLock: { enabled: true, text: "Keep {ref} pixel-exact." },
|
|
151
|
+
} as ConnectedReference],
|
|
152
|
+
provider: "nano-banana-pro",
|
|
153
|
+
referenceFormat: "hybrid",
|
|
154
|
+
})
|
|
155
|
+
expect(unlocked.prompt).not.toContain("pixel-exact")
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("a `manual` reference is mentionable too", () => {
|
|
159
|
+
const out = buildImagePrompt({
|
|
160
|
+
prompt: "a shot of @moodboard:1:style",
|
|
161
|
+
connectedReferences: [{
|
|
162
|
+
id: "m1", defaultName: "Moodboard", source: "manual", url: "https://cdn/mood.png",
|
|
163
|
+
} as ConnectedReference],
|
|
164
|
+
provider: "nano-banana-pro",
|
|
165
|
+
referenceFormat: "hybrid",
|
|
166
|
+
})
|
|
167
|
+
expect(out.prompt).toContain("the style from reference image A")
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("{image:N} and @town:3 coexist — each renders exactly once", () => {
|
|
171
|
+
const out = buildImagePrompt({
|
|
172
|
+
prompt: "put {image:1} beside @plaza:2:background",
|
|
173
|
+
connectedReferences: [town, plaza],
|
|
174
|
+
provider: "nano-banana-pro",
|
|
175
|
+
referenceFormat: "hybrid",
|
|
176
|
+
})
|
|
177
|
+
expect(out.prompt).not.toContain("{image:")
|
|
178
|
+
expect(out.prompt).not.toContain("@plaza")
|
|
179
|
+
// Both URLs attach, each exactly once (the mention does NOT filter its ref
|
|
180
|
+
// out of `connectedReferences`, and the New-path merge dedups by URL).
|
|
181
|
+
expect(out.referenceImageUrls?.filter((u) => u === "https://cdn/town.png")).toHaveLength(1)
|
|
182
|
+
expect(out.referenceImageUrls?.filter((u) => u === "https://cdn/plaza.png")).toHaveLength(1)
|
|
183
|
+
const backgroundPhrases = out.prompt.match(/the background from reference image/g) ?? []
|
|
184
|
+
expect(backgroundPhrases).toHaveLength(1)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* THE GATE-ARM GUARD (program plan §6, Leg C: "must exist or the leg silently
|
|
190
|
+
* no-ops"). An IMAGE-ONLY reference list — no wired character, no location, no
|
|
191
|
+
* extras — is the commonest studio payload, and it is exactly the case that
|
|
192
|
+
* reaches Phase 0 ONLY through the `hasImageMentionTokens` arm added to the
|
|
193
|
+
* `prompt-builder.ts` gate. Delete that arm and this test is the one that fails.
|
|
194
|
+
*/
|
|
195
|
+
describe("Phase-0 gate arm: image-only references", () => {
|
|
196
|
+
it("image-only refs + one mention → resolves (no character, no location, no extras)", () => {
|
|
197
|
+
const out = buildImagePrompt({
|
|
198
|
+
prompt: "a wide shot of @town:1:background at dusk",
|
|
199
|
+
connectedReferences: [town],
|
|
200
|
+
provider: "nano-banana-pro",
|
|
201
|
+
referenceFormat: "hybrid",
|
|
202
|
+
})
|
|
203
|
+
expect(out.prompt).toContain("the background from reference image A")
|
|
204
|
+
expect(out.prompt).not.toContain("@town")
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* THE BYTE-PARITY GUARD (program plan §6, Leg C, the second mandatory test).
|
|
210
|
+
*
|
|
211
|
+
* The expected values below are FIXTURES CAPTURED FROM THE PRE-CHANGE TREE (the
|
|
212
|
+
* branch point's `prompt-builder.ts`), not re-derived from the function under
|
|
213
|
+
* test — a self-comparison would pin nothing. The gate is TOKEN presence, so a
|
|
214
|
+
* mention-free graph never enters Phase 0 and its output is unchanged BY
|
|
215
|
+
* CONSTRUCTION; these fixtures are what make that claim falsifiable.
|
|
216
|
+
*/
|
|
217
|
+
describe("mention-free graphs are byte-identical to the pre-change tree", () => {
|
|
218
|
+
it("image-only, no mention", () => {
|
|
219
|
+
const out = buildImagePrompt({
|
|
220
|
+
prompt: "a wide shot of a quiet town square",
|
|
221
|
+
connectedReferences: [town],
|
|
222
|
+
provider: "nano-banana-pro",
|
|
223
|
+
referenceFormat: "hybrid",
|
|
224
|
+
})
|
|
225
|
+
expect(out.prompt).toBe("A wide shot of a quiet town square")
|
|
226
|
+
expect(out.referenceImageUrls).toEqual(["https://cdn/town.png"])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("two images, no mention", () => {
|
|
230
|
+
const out = buildImagePrompt({
|
|
231
|
+
prompt: "a wide shot of a quiet town square",
|
|
232
|
+
connectedReferences: [town, plaza],
|
|
233
|
+
provider: "nano-banana-pro",
|
|
234
|
+
referenceFormat: "hybrid",
|
|
235
|
+
})
|
|
236
|
+
expect(out.prompt).toBe("A wide shot of a quiet town square")
|
|
237
|
+
expect(out.referenceImageUrls).toEqual(["https://cdn/town.png", "https://cdn/plaza.png"])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("character mention + an unmentioned image", () => {
|
|
241
|
+
const out = buildImagePrompt({
|
|
242
|
+
prompt: "@kira:1 walks through the square",
|
|
243
|
+
connectedReferences: [kira, town],
|
|
244
|
+
provider: "nano-banana-pro",
|
|
245
|
+
referenceFormat: "hybrid",
|
|
246
|
+
})
|
|
247
|
+
expect(out.prompt).toBe("the person from reference image A walks through the square")
|
|
248
|
+
expect(out.referenceImageUrls).toEqual(["https://cdn/kira.png", "https://cdn/town.png"])
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("a ref whose name slugs to a GRAMMAR-INVALID slug never arms the gate", () => {
|
|
252
|
+
// "3D Render" → "3d-render": non-empty, but a leading digit is unparseable,
|
|
253
|
+
// so `knownImageSlugsFromRefs` drops it and no token can ever match it.
|
|
254
|
+
const out = buildImagePrompt({
|
|
255
|
+
prompt: "a shot of @3d-render:1",
|
|
256
|
+
connectedReferences: [{
|
|
257
|
+
id: "r1", defaultName: "3D Render", source: "wired-image", url: "https://cdn/r.png",
|
|
258
|
+
} as ConnectedReference],
|
|
259
|
+
provider: "nano-banana-pro",
|
|
260
|
+
referenceFormat: "hybrid",
|
|
261
|
+
})
|
|
262
|
+
expect(out.prompt).toContain("@3d-render:1")
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it("an isExtraRef media ref is NOT mentionable (it renders through the extras path)", () => {
|
|
266
|
+
const out = buildImagePrompt({
|
|
267
|
+
prompt: "a shot of @town:1:background",
|
|
268
|
+
connectedReferences: [{ ...town, isExtraRef: true } as ConnectedReference],
|
|
269
|
+
provider: "nano-banana-pro",
|
|
270
|
+
referenceFormat: "hybrid",
|
|
271
|
+
})
|
|
272
|
+
expect(out.prompt).toContain("@town:1:background")
|
|
273
|
+
expect(out.prompt).not.toContain("the background from reference image")
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* CROSS-GRAMMAR: a LOCATION `bucket/variant` token must never be claimed — not
|
|
279
|
+
* even in truncated form — by the image pass. The image pass is the only one of
|
|
280
|
+
* the three that SPLICES its match, so a truncated claim does not merely fail
|
|
281
|
+
* to resolve, it corrupts the model-facing prompt with a dangling `/variant`.
|
|
282
|
+
*/
|
|
283
|
+
describe("a location bucket/variant token is never claimed by the image pass", () => {
|
|
284
|
+
const oldLibraryImage: ConnectedReference = {
|
|
285
|
+
id: "n3", defaultName: "Old Library", source: "wired-image",
|
|
286
|
+
url: "https://cdn/old-library-photo.png",
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
it("images-only graph: the whole token stays literal, no `/rain` left dangling", () => {
|
|
290
|
+
const out = buildImagePrompt({
|
|
291
|
+
prompt: "a shot of @old-library:1:weather/rain",
|
|
292
|
+
connectedReferences: [oldLibraryImage],
|
|
293
|
+
provider: "nano-banana-pro",
|
|
294
|
+
referenceFormat: "hybrid",
|
|
295
|
+
})
|
|
296
|
+
expect(out.prompt).toContain("@old-library:1:weather/rain")
|
|
297
|
+
// The corruption this pins shut: the truncated claim spliced
|
|
298
|
+
// `@old-library:1:weather` and left `/rain` welded to the binding phrase.
|
|
299
|
+
expect(out.prompt).not.toContain("reference image A/rain")
|
|
300
|
+
expect(out.prompt).not.toContain("/rain\n")
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it("4-part location token (variant + mode) is left alone too", () => {
|
|
304
|
+
const out = buildImagePrompt({
|
|
305
|
+
prompt: "a shot of @old-library:1:weather/rain:style",
|
|
306
|
+
connectedReferences: [oldLibraryImage],
|
|
307
|
+
provider: "nano-banana-pro",
|
|
308
|
+
referenceFormat: "hybrid",
|
|
309
|
+
})
|
|
310
|
+
expect(out.prompt).toContain("@old-library:1:weather/rain:style")
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it("PRECEDENCE: a shared name + an unresolvable variant does not demote the location to slot B", () => {
|
|
314
|
+
// The location pass leaves the token alone (the node carries no
|
|
315
|
+
// `weather/rain` variant); the image pass must not then mangle it, bind the
|
|
316
|
+
// upload to slot A and push the user's actual location image to slot B.
|
|
317
|
+
const out = buildImagePrompt({
|
|
318
|
+
prompt: "a shot of @old-library:1:weather/rain",
|
|
319
|
+
connectedReferences: [library, oldLibraryImage],
|
|
320
|
+
provider: "nano-banana-pro",
|
|
321
|
+
referenceFormat: "hybrid",
|
|
322
|
+
})
|
|
323
|
+
expect(out.prompt).toContain("@old-library:1:weather/rain")
|
|
324
|
+
expect(out.referenceImageUrls[0]).toBe("https://cdn/library.png")
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("a slash BETWEEN two image mentions still resolves both", () => {
|
|
328
|
+
const out = buildImagePrompt({
|
|
329
|
+
prompt: "@town:1/@plaza:2",
|
|
330
|
+
connectedReferences: [town, plaza],
|
|
331
|
+
provider: "nano-banana-pro",
|
|
332
|
+
referenceFormat: "hybrid",
|
|
333
|
+
})
|
|
334
|
+
expect(out.prompt).not.toContain("@town")
|
|
335
|
+
expect(out.prompt).not.toContain("@plaza")
|
|
336
|
+
expect(out.referenceImageUrls).toEqual([
|
|
337
|
+
"https://cdn/town.png",
|
|
338
|
+
"https://cdn/plaza.png",
|
|
339
|
+
])
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* LEGACY (the kill-switch path). `IMAGE_REFERENCE_FORMAT=legacy` reverts the
|
|
345
|
+
* whole leg: there is no legacy image resolver, so a token stays literal text
|
|
346
|
+
* and the reference attaches exactly as it does today.
|
|
347
|
+
*/
|
|
348
|
+
describe("LEGACY reference format: image mentions stay literal", () => {
|
|
349
|
+
it("@town:3 is left verbatim and the URL still attaches", () => {
|
|
350
|
+
const out = buildImagePrompt({
|
|
351
|
+
prompt: "a wide shot of @town:3 at dusk",
|
|
352
|
+
connectedReferences: [town],
|
|
353
|
+
provider: "nano-banana-pro",
|
|
354
|
+
// no referenceFormat → legacy (the prod default)
|
|
355
|
+
})
|
|
356
|
+
expect(out.prompt).toContain("@town:3")
|
|
357
|
+
expect(out.prompt).not.toContain("reference image A")
|
|
358
|
+
expect(out.referenceImageUrls).toContain("https://cdn/town.png")
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it("@town:3:background is left verbatim too", () => {
|
|
362
|
+
const out = buildImagePrompt({
|
|
363
|
+
prompt: "a wide shot of @town:3:background at dusk",
|
|
364
|
+
connectedReferences: [town],
|
|
365
|
+
provider: "nano-banana-pro",
|
|
366
|
+
})
|
|
367
|
+
expect(out.prompt).toContain("@town:3:background")
|
|
368
|
+
expect(out.prompt).not.toContain("the background from reference image")
|
|
369
|
+
})
|
|
370
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { readDirectionFields, readStructuredFields } from "../read-node-direction.js"
|
|
3
|
+
import {
|
|
4
|
+
DIRECTION_ARRAY_CEILING,
|
|
5
|
+
DIRECTION_KEYS,
|
|
6
|
+
renderDirectionHints,
|
|
7
|
+
} from "../direction-registry.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The narrow readers are the ONLY thing standing between untrusted persisted
|
|
11
|
+
* node JSONB (`workflows.nodes` — import, MCP write, node preset, a
|
|
12
|
+
* Studio-emitted graph) and the prompt text a model sees. These cases pin the
|
|
13
|
+
* VALUE SHAPE contract; the accepted KEY SET is not pinned here on purpose —
|
|
14
|
+
* it is derived from `DIRECTION_FIELDS`, whose order + membership are pinned by
|
|
15
|
+
* `direction-registry.test.ts`. A hand list here would be exactly the
|
|
16
|
+
* "remember to update the list" the registry walk removes.
|
|
17
|
+
*/
|
|
18
|
+
describe("readDirectionFields", () => {
|
|
19
|
+
it("rejects non-object roots", () => {
|
|
20
|
+
expect(readDirectionFields(undefined)).toBeUndefined()
|
|
21
|
+
expect(readDirectionFields(null)).toBeUndefined()
|
|
22
|
+
expect(readDirectionFields("shotSize")).toBeUndefined()
|
|
23
|
+
expect(readDirectionFields(42)).toBeUndefined()
|
|
24
|
+
expect(readDirectionFields([])).toBeUndefined()
|
|
25
|
+
expect(readDirectionFields([{ shotSize: "medium-shot" }])).toBeUndefined()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("returns undefined, never {}, when nothing survives", () => {
|
|
29
|
+
// Load-bearing: `{}` is a DEFINED direction, which would flip the call
|
|
30
|
+
// sites' `...(x !== undefined ? { x } : {})` spread on and take the join
|
|
31
|
+
// branch instead of the exact no-op branch.
|
|
32
|
+
expect(readDirectionFields({})).toBeUndefined()
|
|
33
|
+
expect(readDirectionFields({ shotSize: "" })).toBeUndefined()
|
|
34
|
+
expect(readDirectionFields({ mood: [] })).toBeUndefined()
|
|
35
|
+
expect(readDirectionFields({ mood: ["", 5, null] })).toBeUndefined()
|
|
36
|
+
expect(readDirectionFields({ nothing: "here" })).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("keeps a registry key and drops everything unrecognised", () => {
|
|
40
|
+
expect(readDirectionFields({ shotSize: "medium-shot", bogus: "x" })).toEqual({
|
|
41
|
+
shotSize: "medium-shot",
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("accepts an array on any key and filters junk entries out of it", () => {
|
|
46
|
+
expect(readDirectionFields({ mood: ["serene", "", 5, null, "tense"] })).toEqual({
|
|
47
|
+
mood: ["serene", "tense"],
|
|
48
|
+
})
|
|
49
|
+
// An array on a single-pick key is legal at the reader; the per-dimension
|
|
50
|
+
// cap is the renderer's slice, never a drop here.
|
|
51
|
+
expect(readDirectionFields({ style: ["anime", "cinematic"] })).toEqual({
|
|
52
|
+
style: ["anime", "cinematic"],
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("drops non-string / over-length ids", () => {
|
|
57
|
+
expect(readDirectionFields({ shotSize: 123, style: "anime" })).toEqual({ style: "anime" })
|
|
58
|
+
expect(readDirectionFields({ shotSize: { id: "medium-shot" } })).toBeUndefined()
|
|
59
|
+
const overLong = "x".repeat(101)
|
|
60
|
+
expect(readDirectionFields({ shotSize: overLong })).toBeUndefined()
|
|
61
|
+
expect(readDirectionFields({ shotSize: "x".repeat(100) })).toEqual({
|
|
62
|
+
shotSize: "x".repeat(100),
|
|
63
|
+
})
|
|
64
|
+
expect(readDirectionFields({ mood: [overLong, "serene"] })).toEqual({ mood: ["serene"] })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("caps an array at the wire ceiling, filtering junk BEFORE the cap", () => {
|
|
68
|
+
// Cardinality is bounded, not just per-id length: node data is validated
|
|
69
|
+
// only as `z.record(z.string(), z.unknown())` on write, so an unbounded
|
|
70
|
+
// array would otherwise reach `renderDirectionHints`' includes-dedupe.
|
|
71
|
+
const many = Array.from({ length: DIRECTION_ARRAY_CEILING + 20 }, (_, i) => `id-${i}`)
|
|
72
|
+
expect(readDirectionFields({ mood: many })).toEqual({
|
|
73
|
+
mood: many.slice(0, DIRECTION_ARRAY_CEILING),
|
|
74
|
+
})
|
|
75
|
+
// Junk entries must not consume the budget: 8 unusable entries in front of
|
|
76
|
+
// the real ids still leaves a full ceiling of real ids.
|
|
77
|
+
const junk = [...Array.from({ length: DIRECTION_ARRAY_CEILING }, () => ""), ...many]
|
|
78
|
+
expect(readDirectionFields({ mood: junk })).toEqual({
|
|
79
|
+
mood: many.slice(0, DIRECTION_ARRAY_CEILING),
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("bounds the work a stored blob can force on the renderer", () => {
|
|
84
|
+
// Pre-fix this pair was quadratic in the stored array's length (the dedupe
|
|
85
|
+
// scan ran over the FULL array before the per-row slice), so a node any
|
|
86
|
+
// authenticated user can persist blocked the event loop for seconds in the
|
|
87
|
+
// orchestrator and in the user's tab. No timing assertion — the default
|
|
88
|
+
// vitest timeout is the guard.
|
|
89
|
+
const huge = Array.from({ length: 200_000 }, (_, i) => `id-${i}`)
|
|
90
|
+
const direction = readDirectionFields({ mood: huge, lightingStyle: huge })
|
|
91
|
+
expect(direction?.mood).toHaveLength(DIRECTION_ARRAY_CEILING)
|
|
92
|
+
expect(() => renderDirectionHints(direction, { surface: "image" })).not.toThrow()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("honors every registry key by construction (a new dimension needs no edit here)", () => {
|
|
96
|
+
const everyKey = Object.fromEntries(DIRECTION_KEYS.map((k) => [k, "some-id"]))
|
|
97
|
+
expect(Object.keys(readDirectionFields(everyKey) ?? {}).sort()).toEqual(
|
|
98
|
+
[...DIRECTION_KEYS].sort(),
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe("readStructuredFields", () => {
|
|
104
|
+
it("rejects non-object roots and returns undefined when nothing survives", () => {
|
|
105
|
+
expect(readStructuredFields(undefined)).toBeUndefined()
|
|
106
|
+
expect(readStructuredFields(null)).toBeUndefined()
|
|
107
|
+
expect(readStructuredFields("person")).toBeUndefined()
|
|
108
|
+
expect(readStructuredFields([])).toBeUndefined()
|
|
109
|
+
expect(readStructuredFields({})).toBeUndefined()
|
|
110
|
+
expect(readStructuredFields({ person: "hello" })).toBeUndefined()
|
|
111
|
+
expect(readStructuredFields({ person: {}, styling: [] })).toBeUndefined()
|
|
112
|
+
expect(readStructuredFields({ nothing: "here" })).toBeUndefined()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("drops junk field values instead of rendering them verbatim", () => {
|
|
116
|
+
// `renderStructuredFields` never throws on junk but DOES render it —
|
|
117
|
+
// `person: { age: "drop table" }` would become "Subject: drop table years
|
|
118
|
+
// old." inside `jobs.input_data.prompt`. The field table is what blocks it.
|
|
119
|
+
expect(readStructuredFields({ person: { age: "drop table" } })).toBeUndefined()
|
|
120
|
+
expect(readStructuredFields({ person: { age: Number.NaN } })).toBeUndefined()
|
|
121
|
+
expect(readStructuredFields({ person: { gender: "robot" } })).toBeUndefined()
|
|
122
|
+
expect(readStructuredFields({ person: { hair: 7 } })).toBeUndefined()
|
|
123
|
+
expect(readStructuredFields({ person: { hair: "x".repeat(201) } })).toBeUndefined()
|
|
124
|
+
expect(readStructuredFields({ person: { age: 34, bogus: "x" } })).toEqual({
|
|
125
|
+
person: { age: 34 },
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("round-trips every group plus the mood shorthand", () => {
|
|
130
|
+
const full = {
|
|
131
|
+
person: {
|
|
132
|
+
age: 34,
|
|
133
|
+
gender: "woman",
|
|
134
|
+
hair: "auburn",
|
|
135
|
+
eyes: "green",
|
|
136
|
+
expression: "wry",
|
|
137
|
+
profession: "archivist",
|
|
138
|
+
warriorType: "ranger",
|
|
139
|
+
},
|
|
140
|
+
styling: { mood: "brooding", lighting: "soft", aesthetic: "noir", colorLook: "teal" },
|
|
141
|
+
setting: { era: "1970s", atmosphere: "humid", backdrop: "a rain-slick street" },
|
|
142
|
+
camera: { framing: "medium", motion: "slow push", format: "35mm" },
|
|
143
|
+
lens: { focalLength: "85mm", aperture: "1.4" },
|
|
144
|
+
mood: "brooding",
|
|
145
|
+
}
|
|
146
|
+
expect(readStructuredFields(full)).toEqual(full)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("keeps the surviving fields of a partially-junk group", () => {
|
|
150
|
+
expect(
|
|
151
|
+
readStructuredFields({ person: { age: 34, gender: "robot" }, styling: "nope" }),
|
|
152
|
+
).toEqual({ person: { age: 34 } })
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -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
|
/**
|