@nodaro/prompts 1.38.0 → 1.40.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 (130) hide show
  1. package/dist/chunk-2EW5B7ZV.cjs +86 -0
  2. package/dist/chunk-2EW5B7ZV.cjs.map +1 -0
  3. package/dist/chunk-2P26SZX5.cjs +414 -0
  4. package/dist/chunk-2P26SZX5.cjs.map +1 -0
  5. package/dist/{chunk-7XKHDXCL.cjs → chunk-3U7X6JZN.cjs} +13 -12
  6. package/dist/chunk-3U7X6JZN.cjs.map +1 -0
  7. package/dist/chunk-4GKTOVFM.cjs +8 -0
  8. package/dist/chunk-4GKTOVFM.cjs.map +1 -0
  9. package/dist/{chunk-HRBBNODV.js → chunk-6GVFJLDL.js} +4 -3
  10. package/dist/chunk-6GVFJLDL.js.map +1 -0
  11. package/dist/{chunk-LVSH7K7R.cjs → chunk-C3NHNECH.cjs} +165 -88
  12. package/dist/chunk-C3NHNECH.cjs.map +1 -0
  13. package/dist/{chunk-CEPVQQVO.js → chunk-C46256GZ.js} +43 -5
  14. package/dist/chunk-C46256GZ.js.map +1 -0
  15. package/dist/{chunk-JKCR6WRZ.cjs → chunk-DQFQJECU.cjs} +43 -5
  16. package/dist/chunk-DQFQJECU.cjs.map +1 -0
  17. package/dist/{chunk-QDEWBBUR.js → chunk-DUXX6CBX.js} +12 -143
  18. package/dist/chunk-DUXX6CBX.js.map +1 -0
  19. package/dist/chunk-EAYEZXWT.js +388 -0
  20. package/dist/chunk-EAYEZXWT.js.map +1 -0
  21. package/dist/chunk-GILUVQQJ.js +6 -0
  22. package/dist/chunk-GILUVQQJ.js.map +1 -0
  23. package/dist/{chunk-KIYNOOPJ.js → chunk-GTDKDD7Y.js} +12 -6
  24. package/dist/chunk-GTDKDD7Y.js.map +1 -0
  25. package/dist/{chunk-QBEZMR2F.js → chunk-HQTHDTHN.js} +137 -62
  26. package/dist/chunk-HQTHDTHN.js.map +1 -0
  27. package/dist/{chunk-TLVX5PXD.cjs → chunk-IUCNJAHW.cjs} +13 -7
  28. package/dist/chunk-IUCNJAHW.cjs.map +1 -0
  29. package/dist/{chunk-ZFIXQRBZ.cjs → chunk-LXPKTI2K.cjs} +12 -83
  30. package/dist/chunk-LXPKTI2K.cjs.map +1 -0
  31. package/dist/{chunk-CVPB2N3Z.cjs → chunk-LZ53F5BN.cjs} +103 -5
  32. package/dist/chunk-LZ53F5BN.cjs.map +1 -0
  33. package/dist/{chunk-4FDNVNJE.js → chunk-N6VMPG5S.js} +12 -79
  34. package/dist/chunk-N6VMPG5S.js.map +1 -0
  35. package/dist/{chunk-LK2CZK5U.cjs → chunk-PWWM7Q3T.cjs} +7 -7
  36. package/dist/{chunk-LK2CZK5U.cjs.map → chunk-PWWM7Q3T.cjs.map} +1 -1
  37. package/dist/{chunk-N7TOWXDJ.js → chunk-QDUTMOF4.js} +3 -3
  38. package/dist/{chunk-N7TOWXDJ.js.map → chunk-QDUTMOF4.js.map} +1 -1
  39. package/dist/{chunk-NO6XHX4R.js → chunk-RB2F5JQF.js} +100 -7
  40. package/dist/chunk-RB2F5JQF.js.map +1 -0
  41. package/dist/{chunk-MENBHEAY.cjs → chunk-WRM52ZBN.cjs} +19 -161
  42. package/dist/chunk-WRM52ZBN.cjs.map +1 -0
  43. package/dist/chunk-YS6HOWMW.js +80 -0
  44. package/dist/chunk-YS6HOWMW.js.map +1 -0
  45. package/dist/entries/assembly.cjs +34 -25
  46. package/dist/entries/assembly.d.cts +121 -37
  47. package/dist/entries/assembly.d.ts +121 -37
  48. package/dist/entries/assembly.js +5 -4
  49. package/dist/entries/doctrine.cjs +46 -46
  50. package/dist/entries/doctrine.d.cts +3 -3
  51. package/dist/entries/doctrine.d.ts +3 -3
  52. package/dist/entries/doctrine.js +1 -1
  53. package/dist/entries/picker-catalogs.cjs +42 -42
  54. package/dist/entries/picker-catalogs.js +3 -3
  55. package/dist/entries/presets.cjs +49 -26
  56. package/dist/entries/presets.d.cts +53 -1
  57. package/dist/entries/presets.d.ts +53 -1
  58. package/dist/entries/presets.js +4 -1
  59. package/dist/entries/prompt-builder.cjs +135 -98
  60. package/dist/entries/prompt-builder.d.cts +22 -11
  61. package/dist/entries/prompt-builder.d.ts +22 -11
  62. package/dist/entries/prompt-builder.js +4 -3
  63. package/dist/entries/prompt-fields.cjs +41 -40
  64. package/dist/entries/prompt-fields.d.cts +2 -1
  65. package/dist/entries/prompt-fields.d.ts +2 -1
  66. package/dist/entries/prompt-fields.js +2 -1
  67. package/dist/index.cjs +339 -273
  68. package/dist/index.d.cts +4 -4
  69. package/dist/index.d.ts +4 -4
  70. package/dist/index.js +11 -9
  71. package/dist/{subject-registry-BUswuEVD.d.cts → subject-registry-CQi_CZfJ.d.cts} +78 -9
  72. package/dist/{subject-registry-D7V8PEG-.d.ts → subject-registry-DFiGx49v.d.ts} +78 -9
  73. package/package.json +2 -2
  74. package/src/__tests__/assemble-image-input-style-references.test.ts +173 -0
  75. package/src/__tests__/catalog-id-guard.test.ts +30 -0
  76. package/src/__tests__/factory-preset-previews.test.ts +123 -0
  77. package/src/__tests__/factory-preset-references.test.ts +222 -0
  78. package/src/__tests__/factory-presets-prototype-keys.test.ts +43 -0
  79. package/src/__tests__/frame-reference-slots.test.ts +43 -0
  80. package/src/__tests__/gemini-omni-inputs.test.ts +12 -10
  81. package/src/__tests__/provider-prompt-doctrine.test.ts +14 -0
  82. package/src/__tests__/user-direction-refs.test.ts +180 -0
  83. package/src/__tests__/veo-i2v-inputs.test.ts +16 -11
  84. package/src/assemble-image-input.ts +114 -13
  85. package/src/catalog-id-guard.ts +18 -2
  86. package/src/color-names.ts +192 -0
  87. package/src/direction-registry.ts +66 -5
  88. package/src/entries/assembly.ts +1 -0
  89. package/src/entries/prompt-builder.ts +2 -0
  90. package/src/factory-presets/generate-image.ts +29 -0
  91. package/src/factory-presets/index.ts +6 -1
  92. package/src/factory-presets/preview-files.generated.ts +4 -0
  93. package/src/factory-presets/previews.ts +40 -0
  94. package/src/factory-presets/references.ts +81 -0
  95. package/src/factory-presets/shared-image.ts +4 -0
  96. package/src/factory-presets/types.ts +8 -0
  97. package/src/frame-reference-slots.ts +92 -0
  98. package/src/gemini-omni-inputs.ts +23 -22
  99. package/src/image-reference-doctrine.ts +3 -3
  100. package/src/image-token-pattern.ts +13 -0
  101. package/src/node-prompt-fields.ts +5 -3
  102. package/src/own-entry.ts +11 -0
  103. package/src/prompt-builder.ts +4 -5
  104. package/src/prompt-style-section.ts +3 -3
  105. package/src/prompt-wizard-categories.ts +2 -0
  106. package/src/provider-prompt-doctrine.ts +45 -3
  107. package/src/ref-binding.ts +28 -3
  108. package/src/resolve-prompt.ts +2 -1
  109. package/src/seedance-2-inputs.ts +15 -40
  110. package/src/user-direction-refs.ts +110 -0
  111. package/src/veo-i2v-inputs.ts +24 -17
  112. package/src/video-reference-resolver.ts +1 -1
  113. package/dist/chunk-4FDNVNJE.js.map +0 -1
  114. package/dist/chunk-7XKHDXCL.cjs.map +0 -1
  115. package/dist/chunk-CEPVQQVO.js.map +0 -1
  116. package/dist/chunk-CVPB2N3Z.cjs.map +0 -1
  117. package/dist/chunk-HRBBNODV.js.map +0 -1
  118. package/dist/chunk-JKCR6WRZ.cjs.map +0 -1
  119. package/dist/chunk-KIYNOOPJ.js.map +0 -1
  120. package/dist/chunk-LVSH7K7R.cjs.map +0 -1
  121. package/dist/chunk-MENBHEAY.cjs.map +0 -1
  122. package/dist/chunk-NO6XHX4R.js.map +0 -1
  123. package/dist/chunk-QBEZMR2F.js.map +0 -1
  124. package/dist/chunk-QDEWBBUR.js.map +0 -1
  125. package/dist/chunk-TLVX5PXD.cjs.map +0 -1
  126. package/dist/chunk-UJKFHPZ7.js +0 -37
  127. package/dist/chunk-UJKFHPZ7.js.map +0 -1
  128. package/dist/chunk-W5SCZIOB.cjs +0 -43
  129. package/dist/chunk-W5SCZIOB.cjs.map +0 -1
  130. package/dist/chunk-ZFIXQRBZ.cjs.map +0 -1
@@ -13,19 +13,20 @@ describe("resolveGeminiOmniI2vInputs", () => {
13
13
  const FIRST = "https://r2/anchor.png"
14
14
  const refs = (n: number) => Array.from({ length: n }, (_, i) => `https://r2/ref-${i + 1}.png`)
15
15
 
16
- it("binds the roles: image 1 is the opening frame, the rest are identities — not frames", () => {
16
+ it("binds the roles: the references first (identities, not frames), the start frame appended after them", () => {
17
17
  const r = resolveGeminiOmniI2vInputs({ prompt: "a walk on the beach", firstFrameUrl: FIRST, refImageUrls: refs(3) })
18
- expect(r.imageUrls).toEqual([FIRST, ...refs(3)])
18
+ expect(r.imageUrls).toEqual([...refs(3), FIRST])
19
19
  expect(r.promptSuffix).toBe(
20
- "Use @image_1 as the opening (first) frame of the video. " +
21
- "@image_2 through @image_4 are identity references for this shot's subjects — match each subject's exact appearance; they are not frames.",
20
+ "Use @image_4 as the opening (first) frame of the video. " +
21
+ "@image_1 through @image_3 are identity references for this shot's subjects — match each subject's exact appearance; they are not frames.",
22
22
  )
23
23
  expect(r.droppedRefImages).toBe(0)
24
24
  })
25
25
 
26
26
  it("a single reference gets the singular sentence", () => {
27
27
  const r = resolveGeminiOmniI2vInputs({ firstFrameUrl: FIRST, refImageUrls: refs(1) })
28
- expect(r.promptSuffix).toContain("@image_2 is an identity reference")
28
+ expect(r.promptSuffix).toContain("@image_1 is an identity reference")
29
+ expect(r.promptSuffix).toContain("Use @image_2 as the opening (first) frame")
29
30
  expect(r.promptSuffix).not.toContain("through")
30
31
  })
31
32
 
@@ -37,11 +38,12 @@ describe("resolveGeminiOmniI2vInputs", () => {
37
38
  it("drops TRAILING references to fit the 7-input quota — the start frame is never the one that goes", () => {
38
39
  const r = resolveGeminiOmniI2vInputs({ firstFrameUrl: FIRST, refImageUrls: refs(9) })
39
40
  expect(r.imageUrls).toHaveLength(7)
40
- expect(r.imageUrls[0]).toBe(FIRST)
41
- expect(r.imageUrls.at(-1)).toBe("https://r2/ref-6.png")
41
+ expect(r.imageUrls.at(-1)).toBe(FIRST)
42
+ expect(r.imageUrls[5]).toBe("https://r2/ref-6.png")
42
43
  expect(r.droppedRefImages).toBe(3)
43
- // The binding names exactly the kept span.
44
- expect(r.promptSuffix).toContain("@image_2 through @image_7")
44
+ // The binding names exactly the kept span, and the frame's own seat.
45
+ expect(r.promptSuffix).toContain("@image_1 through @image_6")
46
+ expect(r.promptSuffix).toContain("Use @image_7 as the opening (first) frame")
45
47
  })
46
48
 
47
49
  it("a connected source video eats two slots (images + 2×videos ≤ 7)", () => {
@@ -62,7 +64,7 @@ describe("resolveGeminiOmniI2vInputs", () => {
62
64
 
63
65
  it("skips empty/undefined reference entries without burning slots", () => {
64
66
  const r = resolveGeminiOmniI2vInputs({ firstFrameUrl: FIRST, refImageUrls: [undefined, "", ...refs(2)] })
65
- expect(r.imageUrls).toEqual([FIRST, ...refs(2)])
67
+ expect(r.imageUrls).toEqual([...refs(2), FIRST])
66
68
  expect(r.droppedRefImages).toBe(0)
67
69
  })
68
70
  })
@@ -98,6 +98,20 @@ describe("PROVIDER_PROMPT_DOCTRINES", () => {
98
98
  expect(d.doctrine).not.toMatch(/higher quality/i)
99
99
  })
100
100
 
101
+ it("Vidu Q4 has its own doctrine carrying the two-endpoint contract", () => {
102
+ const d = getPromptDoctrine("vidu-q4")!
103
+ expect(d.providers).toEqual(["vidu-q4"])
104
+ // Mode pick, the frame fold and the hard reference-audio contract.
105
+ expect(d.doctrine).toMatch(/reference-to-video/)
106
+ expect(d.doctrine).toMatch(/prompt is REQUIRED/)
107
+ expect(d.doctrine).toMatch(/no text-only mode/i)
108
+ expect(d.doctrine).toMatch(/MP3, each 3-12 seconds/)
109
+ expect(d.doctrine).toMatch(/1-15 reference images/)
110
+ // The binding the editor actually emits, not the contract's prose form.
111
+ expect(d.doctrine).toContain("@image_N")
112
+ expect(d.doctrine).toMatch(/20,000 characters/)
113
+ })
114
+
101
115
  it("Gemini Omni Flash rides the sibling's doctrine (identical request surface)", () => {
102
116
  const d = getPromptDoctrine("gemini-omni-flash")!
103
117
  expect(d).toBe(getPromptDoctrine("gemini-omni-video"))
@@ -0,0 +1,180 @@
1
+ /**
2
+ * User-owned direction references: `direction.colorLook: "palette:<id>"` and
3
+ * `direction.style: "moodboard:<id>"`. The grammar, the wording of the
4
+ * resolved clauses, and how a resolved clause takes its row's place in the
5
+ * fold — without changing a single byte of a fold that carries none.
6
+ */
7
+ import { describe, it, expect } from "vitest"
8
+ import {
9
+ parseUserDirectionRef,
10
+ userDirectionRefValue,
11
+ renderPaletteClause,
12
+ renderMoodboardClause,
13
+ styleReferenceDescription,
14
+ } from "../user-direction-refs.js"
15
+ import { nearestColorName } from "../color-names.js"
16
+ import {
17
+ DIRECTION_FIELDS,
18
+ directionKeyForUserRef,
19
+ userRefKindOfDirectionKey,
20
+ renderDirectionHints,
21
+ renderDirectionHintClauses,
22
+ } from "../direction-registry.js"
23
+ import { partitionStyleClauses } from "../prompt-style-section.js"
24
+ import { getColorLookPromptHint } from "../color-look.js"
25
+ import { getStylePromptHint } from "../style.js"
26
+
27
+ const UUID = "0f9e8d7c-6b5a-4c3d-9e2f-1a2b3c4d5e6f"
28
+ const PALETTE = `palette:${UUID}`
29
+ const MOODBOARD = `moodboard:${UUID}`
30
+ const IMAGE = { surface: "image" as const }
31
+
32
+ describe("parseUserDirectionRef", () => {
33
+ it("reads a palette and a moodboard reference", () => {
34
+ expect(parseUserDirectionRef(PALETTE)).toEqual({ kind: "palette", id: UUID, wellFormed: true })
35
+ expect(parseUserDirectionRef(MOODBOARD)).toEqual({ kind: "moodboard", id: UUID, wellFormed: true })
36
+ })
37
+
38
+ it("marks a prefix followed by anything but a uuid as malformed, not as a catalog id", () => {
39
+ expect(parseUserDirectionRef("palette:warm")).toEqual({ kind: "palette", id: "warm", wellFormed: false })
40
+ expect(parseUserDirectionRef("moodboard:")).toEqual({ kind: "moodboard", id: "", wellFormed: false })
41
+ })
42
+
43
+ it("is null for a catalog id, an empty string and a non-string", () => {
44
+ expect(parseUserDirectionRef("teal-orange")).toBeNull()
45
+ expect(parseUserDirectionRef("")).toBeNull()
46
+ expect(parseUserDirectionRef(undefined)).toBeNull()
47
+ expect(parseUserDirectionRef(["palette:x"])).toBeNull()
48
+ })
49
+
50
+ it("builds the wire value back", () => {
51
+ expect(userDirectionRefValue("palette", UUID)).toBe(PALETTE)
52
+ expect(userDirectionRefValue("moodboard", UUID)).toBe(MOODBOARD)
53
+ })
54
+ })
55
+
56
+ describe("which direction keys take a reference", () => {
57
+ it("colorLook takes a palette and style takes a moodboard", () => {
58
+ expect(directionKeyForUserRef("palette")).toBe("colorLook")
59
+ expect(directionKeyForUserRef("moodboard")).toBe("style")
60
+ expect(userRefKindOfDirectionKey("colorLook")).toBe("palette")
61
+ expect(userRefKindOfDirectionKey("style")).toBe("moodboard")
62
+ expect(userRefKindOfDirectionKey("mood")).toBeUndefined()
63
+ expect(userRefKindOfDirectionKey("not-a-key")).toBeUndefined()
64
+ })
65
+
66
+ it("only single-pick rows take one, so a reference is never sliced away by a pick budget", () => {
67
+ const rows = DIRECTION_FIELDS.filter((f) => "userRef" in f)
68
+ expect(rows.map((f) => f.key).sort()).toEqual(["colorLook", "style"])
69
+ for (const row of rows) expect(row.maxPicks, row.key).toBe(1)
70
+ })
71
+ })
72
+
73
+ describe("nearestColorName", () => {
74
+ it("names the primaries and the neutrals plainly", () => {
75
+ expect(nearestColorName("#000000")).toBe("black")
76
+ expect(nearestColorName("#FFFFFF")).toBe("white")
77
+ expect(nearestColorName("#FF0000")).toBe("red")
78
+ expect(nearestColorName("#0000FF")).toBe("blue")
79
+ expect(nearestColorName("#808080")).toBe("gray")
80
+ })
81
+
82
+ it("accepts lowercase hex and answers the same name", () => {
83
+ expect(nearestColorName("#ff0000")).toBe(nearestColorName("#FF0000"))
84
+ })
85
+
86
+ it("answers a name for any valid colour and nothing for an invalid one", () => {
87
+ expect(nearestColorName("#0F4C5C").length).toBeGreaterThan(0)
88
+ expect(nearestColorName("#E3B23C").length).toBeGreaterThan(0)
89
+ expect(nearestColorName("teal")).toBe("")
90
+ expect(nearestColorName("#12345")).toBe("")
91
+ })
92
+
93
+ it("puts a dark blue-green near teal, not near a warm colour", () => {
94
+ expect(nearestColorName("#0F4C5C")).toMatch(/teal|petrol|blue|green/)
95
+ })
96
+ })
97
+
98
+ describe("renderPaletteClause", () => {
99
+ it("names every colour with its hex, in the palette's order", () => {
100
+ const clause = renderPaletteClause(["#0f4c5c", "#E3B23C"])
101
+ expect(clause).toBe(`color palette: ${nearestColorName("#0F4C5C")} #0F4C5C, ${nearestColorName("#E3B23C")} #E3B23C`)
102
+ })
103
+
104
+ it("skips an entry that is not a #RRGGBB colour, and renders nothing for none", () => {
105
+ expect(renderPaletteClause(["#FF0000", "red", "#00FF00"])).toBe("color palette: red #FF0000, " + `${nearestColorName("#00FF00")} #00FF00`)
106
+ expect(renderPaletteClause([])).toBe("")
107
+ expect(renderPaletteClause(["nope"])).toBe("")
108
+ })
109
+ })
110
+
111
+ describe("renderMoodboardClause", () => {
112
+ it("is the description, on one line, trimmed", () => {
113
+ expect(renderMoodboardClause(" Moody Nordic interiors,\n\nsoft daylight ")).toBe("Moody Nordic interiors, soft daylight")
114
+ })
115
+
116
+ it("cannot open a section of its own: newlines collapse", () => {
117
+ expect(renderMoodboardClause("calm\n\n[style]:\nloud")).not.toContain("\n")
118
+ })
119
+
120
+ it("renders nothing without a description", () => {
121
+ expect(renderMoodboardClause(null)).toBe("")
122
+ expect(renderMoodboardClause(undefined)).toBe("")
123
+ expect(renderMoodboardClause(" ")).toBe("")
124
+ })
125
+ })
126
+
127
+ describe("styleReferenceDescription", () => {
128
+ it("tells the model what to take from the first style reference, and keeps the rest short", () => {
129
+ expect(styleReferenceDescription(0)).toMatch(/style reference/)
130
+ expect(styleReferenceDescription(0)).toMatch(/not its subject/)
131
+ expect(styleReferenceDescription(1)).toBe("another style reference")
132
+ })
133
+
134
+ it("carries the image's caption, on one line", () => {
135
+ expect(styleReferenceDescription(1, " linen\nand oak ")).toBe("another style reference (linen and oak)")
136
+ expect(styleReferenceDescription(0, "soft daylight")).toContain("(soft daylight)")
137
+ })
138
+ })
139
+
140
+ describe("the fold with resolved references", () => {
141
+ const refs = new Map([
142
+ [PALETTE, "color palette: deep teal #0F4C5C"],
143
+ [MOODBOARD, "Moody Nordic interiors"],
144
+ ])
145
+
146
+ it("renders a resolved palette in the colorLook row and a moodboard in the style row", () => {
147
+ expect(renderDirectionHints({ colorLook: PALETTE, style: MOODBOARD }, { ...IMAGE, refs })).toEqual([
148
+ "color palette: deep teal #0F4C5C",
149
+ "Moody Nordic interiors",
150
+ ])
151
+ })
152
+
153
+ it("keeps the table's fold order around a resolved clause", () => {
154
+ const hints = renderDirectionHints({ style: MOODBOARD, colorLook: "warm", era: "1970s" }, { ...IMAGE, refs })
155
+ expect(hints[0]).toBe(getColorLookPromptHint("warm"))
156
+ expect(hints[1]).toBe("Moody Nordic interiors")
157
+ })
158
+
159
+ it("lands both on the film line of the [style] section", () => {
160
+ const slots = partitionStyleClauses({ colorLook: PALETTE, style: MOODBOARD }, { ...IMAGE, refs })
161
+ expect(slots.map((c) => c.slot)).toEqual(["film", "film"])
162
+ const clauses = renderDirectionHintClauses({ colorLook: PALETTE }, { ...IMAGE, refs })
163
+ expect(clauses[0]).toMatchObject({ key: "colorLook", family: "look", styleGroup: "film" })
164
+ })
165
+
166
+ it("renders nothing for a reference it was not handed, like an unknown catalog id", () => {
167
+ expect(renderDirectionHints({ colorLook: PALETTE }, IMAGE)).toEqual([])
168
+ expect(renderDirectionHints({ colorLook: PALETTE }, { ...IMAGE, refs: new Map() })).toEqual([])
169
+ })
170
+
171
+ it("renders nothing for a reference on a row that does not take that kind", () => {
172
+ expect(renderDirectionHints({ style: PALETTE, colorLook: MOODBOARD, mood: PALETTE }, { ...IMAGE, refs })).toEqual([])
173
+ })
174
+
175
+ it("leaves a fold with no reference byte-identical, refs or not", () => {
176
+ const direction = { colorLook: "warm", style: "anime", mood: ["happy", "joyful"] }
177
+ expect(renderDirectionHints(direction, { ...IMAGE, refs })).toEqual(renderDirectionHints(direction, IMAGE))
178
+ expect(renderDirectionHints({ style: "anime" }, { ...IMAGE, refs })).toEqual([getStylePromptHint("anime")])
179
+ })
180
+ })
@@ -6,9 +6,10 @@ import { resolveVeoI2vInputs } from "../veo-i2v-inputs.js"
6
6
  * VEO 3.x i2v input resolution. VEO's API makes frame conditioning and
7
7
  * reference ingredients mutually exclusive (one imageUrls array, ≤3, whose
8
8
  * meaning flips with generationType) — so an anchored call that must carry
9
- * identity references moves to REFERENCE_2_VIDEO with the anchor in seat 1.
10
- * References win the seats (the 2026-08-14 standing rule: refs are a must,
11
- * frames additional): the end anchor is dropped in reference mode.
9
+ * identity references moves to REFERENCE_2_VIDEO. The platform frame-slot
10
+ * convention (2026-10-18): the caller's references take the first seats, the
11
+ * frames follow them, and at the 3-seat cap the frames are kept while
12
+ * trailing references drop.
12
13
  */
13
14
  describe("resolveVeoI2vInputs", () => {
14
15
  const FIRST = "https://r2/anchor.png"
@@ -24,26 +25,30 @@ describe("resolveVeoI2vInputs", () => {
24
25
  })
25
26
  })
26
27
 
27
- it("references flip the call to REFERENCE_2_VIDEO with the anchor in seat 1, capped at 3", () => {
28
+ it("references flip the call to REFERENCE_2_VIDEO, references first and the anchor after them, capped at 3", () => {
28
29
  const r = resolveVeoI2vInputs({ prompt: "p", firstFrameUrl: FIRST, refImageUrls: refs(4) })
29
30
  expect(r.generationType).toBe("REFERENCE_2_VIDEO")
30
- expect(r.imageUrls).toEqual([FIRST, "https://r2/ref-1.png", "https://r2/ref-2.png"])
31
+ expect(r.imageUrls).toEqual(["https://r2/ref-1.png", "https://r2/ref-2.png", FIRST])
31
32
  expect(r.droppedRefImages).toBe(2)
32
33
  expect(r.promptSuffix).toBe(
33
- "Use @image_1 as the opening (first) frame of the video. " +
34
- "@image_2 through @image_3 are identity references for this shot's subjects — match each subject's exact appearance; they are not frames.",
34
+ "Use @image_3 as the opening (first) frame of the video. " +
35
+ "@image_1 through @image_2 are identity references for this shot's subjects — match each subject's exact appearance; they are not frames.",
35
36
  )
36
37
  })
37
38
 
38
- it("the end anchor is DROPPED in reference mode — references win the seats", () => {
39
+ it("the end frame is KEPT in reference mode — frames win the seats, a trailing reference drops", () => {
39
40
  const r = resolveVeoI2vInputs({ firstFrameUrl: FIRST, endFrameUrl: "https://r2/end.png", refImageUrls: refs(2) })
40
- expect(r.imageUrls).toEqual([FIRST, "https://r2/ref-1.png", "https://r2/ref-2.png"])
41
- expect(r.droppedEndFrame).toBe(true)
41
+ expect(r.imageUrls).toEqual(["https://r2/ref-1.png", FIRST, "https://r2/end.png"])
42
+ expect(r.droppedRefImages).toBe(1)
43
+ expect(r.droppedEndFrame).toBe(false)
44
+ expect(r.promptSuffix).toContain("Use @image_2 as the opening (first) frame and @image_3 as the closing (last) frame of the video.")
45
+ expect(r.promptSuffix).toContain("@image_1 is an identity reference")
42
46
  })
43
47
 
44
48
  it("a single kept reference gets the singular sentence", () => {
45
49
  const r = resolveVeoI2vInputs({ firstFrameUrl: FIRST, refImageUrls: refs(1) })
46
- expect(r.promptSuffix).toContain("@image_2 is an identity reference")
50
+ expect(r.promptSuffix).toContain("@image_1 is an identity reference")
51
+ expect(r.promptSuffix).toContain("Use @image_2 as the opening (first) frame")
47
52
  })
48
53
 
49
54
  it("suppresses the opening-frame sentence when the prompt already binds it", () => {
@@ -60,6 +60,8 @@ import {
60
60
  type SlottedPromptClause,
61
61
  } from "./prompt-style-section.js"
62
62
  import { keepableDirectionHints } from "./hint-shedding.js"
63
+ import { styleReferenceDescription, type StyleReferenceInput } from "./user-direction-refs.js"
64
+ import { imageReferenceLimit } from "@nodaro/shared/models"
63
65
  import type { CharacterDef, ConnectedReference, DescribedReference, IdentityMeta } from "@nodaro/shared/types"
64
66
 
65
67
  /**
@@ -112,6 +114,23 @@ export interface AssembleImageInput {
112
114
  * is a no-op for it and the result is byte-identical to today).
113
115
  */
114
116
  direction?: DirectionFields
117
+ /**
118
+ * Rendered clauses for the caller's OWN saved resources named in `direction`
119
+ * (`colorLook: "palette:<id>"`, `style: "moodboard:<id>"`), keyed by the
120
+ * direction value. Resolving them is a database read, so the platform does it
121
+ * BEFORE this pure assembly and hands the clauses in; each one folds in its
122
+ * row's slot exactly like a catalog clause (the `[style]` film line), sheds
123
+ * like one, and a reference missing from the map folds nothing.
124
+ */
125
+ directionRefs?: ReadonlyMap<string, string>
126
+ /**
127
+ * Style reference images (a moodboard's images). They only FILL the
128
+ * reference seats the caller's own references leave free — after them, never
129
+ * displacing, renumbering or evicting one — up to the model's reference
130
+ * limit; the rest are not sent. `styleReferencesSent` on the result says how
131
+ * many went. Each carries a short style line (`styleReferenceDescription`).
132
+ */
133
+ styleReferences?: readonly StyleReferenceInput[]
115
134
  /**
116
135
  * Flat subject ids (Person / Styling / props) → folded into the prompt AHEAD
117
136
  * of the direction clauses: the subject is the noun phrase the cinematography
@@ -216,6 +235,7 @@ function renderImageHintPieces(
216
235
  subject: SubjectFields | undefined,
217
236
  direction: DirectionFields | undefined,
218
237
  structured: StructuredPromptFields | undefined,
238
+ directionRefs: ReadonlyMap<string, string> | undefined,
219
239
  ): ImageHintPieces {
220
240
  return {
221
241
  hintClauses: [
@@ -228,6 +248,7 @@ function renderImageHintPieces(
228
248
  ...partitionStyleClauses(direction, {
229
249
  surface: "image",
230
250
  mode: IMAGE_HINT_MODE_DEFAULT,
251
+ ...(directionRefs !== undefined ? { refs: directionRefs } : {}),
231
252
  }),
232
253
  ].filter((c) => c.text.length > 0),
233
254
  structuredFragment: structured ? renderStructuredFields(structured) : "",
@@ -275,7 +296,9 @@ function composePromptText(
275
296
  * Order: (1) compose the prompt text (no-op when no subject/direction/structured),
276
297
  * (2) `buildImagePrompt(...)` — exactly the call the three sites make today,
277
298
  * (3) shed hint clauses and re-assemble while the provider cap overflows,
278
- * (4) optional post-assembly empty-prompt throw (gated by `throwOnEmpty`).
299
+ * (4) with `styleReferences`, fill the free reference seats with them
300
+ * (`fillStyleReferences`; absent = this step does not run at all),
301
+ * (5) optional post-assembly empty-prompt throw (gated by `throwOnEmpty`).
279
302
  *
280
303
  * TRUNCATION ORDERING (step 3): `buildImagePrompt`'s cap clamp cuts the TAIL,
281
304
  * which is ORDER-BLIND — on a low-cap provider (seedream = 3000) a maximal
@@ -293,8 +316,96 @@ function composePromptText(
293
316
  */
294
317
  export function assembleImageInput(
295
318
  input: AssembleImageInput,
296
- ): BuildImagePromptResult {
297
- const pieces = renderImageHintPieces(input.subject, input.direction, input.structured)
319
+ ): AssembleImageResult {
320
+ const style = input.styleReferences
321
+ const result = style !== undefined && style.length > 0
322
+ ? fillStyleReferences(input, style)
323
+ : assembleOnce(input)
324
+
325
+ // Post-assembly empty-prompt check (opt-in): a bound entity / `@`-mention /
326
+ // subject or direction chip — or a style reference's line — could have
327
+ // filled the assembled prompt even if the user typed nothing, so only reject
328
+ // when the FINAL prompt is truly empty.
329
+ if (input.throwOnEmpty && !result.prompt.trim()) {
330
+ throw new Error(
331
+ "No prompt — type one, bind a character, or pick a cinematography direction",
332
+ )
333
+ }
334
+
335
+ return result
336
+ }
337
+
338
+ /** `assembleImageInput`'s result: the builder's, plus the style-reference count. */
339
+ export interface AssembleImageResult extends BuildImagePromptResult {
340
+ /**
341
+ * How many of `styleReferences` were sent (they follow the caller's own
342
+ * references at the end of `referenceImageUrls`). Present only when
343
+ * `styleReferences` were given, so every other result is unchanged.
344
+ */
345
+ styleReferencesSent?: number
346
+ }
347
+
348
+ /**
349
+ * Fill the reference seats the caller's own references leave free with style
350
+ * references, without touching the caller's own.
351
+ *
352
+ * PASS 1 assembles the caller's input alone: its `referenceImageUrls` are the
353
+ * references the caller's request really sends (after the per-provider cap,
354
+ * mention resolution and dedupe). The free seats are the model's limit minus
355
+ * those. PASS 2 appends k style references as manual extra references — the
356
+ * builder's own mechanism, so they get its numbered/lettered lines in either
357
+ * reference format — for the LARGEST k that leaves the caller's references
358
+ * exactly where pass 1 put them, followed by the k style images. Why a check
359
+ * and not arithmetic: the builder caps the RAW reference list with an
360
+ * attach-priority slice (non-variants first), so extra entries can push out a
361
+ * character variant the prompt mentions even when seats looked free. Assembly
362
+ * is pure and k ≤ 12, so trying each k downward is cheap and exact.
363
+ */
364
+ function fillStyleReferences(
365
+ input: AssembleImageInput,
366
+ style: readonly StyleReferenceInput[],
367
+ ): AssembleImageResult {
368
+ const base = assembleOnce(input)
369
+ const own = base.referenceImageUrls ?? []
370
+ const seats = imageReferenceLimit(input.provider) - own.length
371
+ const seen = new Set(own)
372
+ const candidates: StyleReferenceInput[] = []
373
+ for (const s of style) {
374
+ if (typeof s.url !== "string" || s.url.length === 0 || seen.has(s.url)) continue
375
+ seen.add(s.url)
376
+ candidates.push(s)
377
+ }
378
+ for (let k = Math.min(seats, candidates.length); k > 0; k--) {
379
+ const extras = candidates.slice(0, k).map(styleConnectedReference)
380
+ const filled = assembleOnce({
381
+ ...input,
382
+ connectedReferences: [...(input.connectedReferences ?? []), ...extras],
383
+ })
384
+ const urls = filled.referenceImageUrls ?? []
385
+ const keepsOwn = urls.length === own.length + k
386
+ && own.every((url, i) => urls[i] === url)
387
+ && extras.every((ref, j) => urls[own.length + j] === ref.url)
388
+ if (keepsOwn) return { ...filled, styleReferencesSent: k }
389
+ }
390
+ return { ...base, styleReferencesSent: 0 }
391
+ }
392
+
393
+ /** A style reference as the builder's manual extra reference. */
394
+ function styleConnectedReference(s: StyleReferenceInput, index: number): ConnectedReference {
395
+ return {
396
+ id: `style-reference-${index + 1}`,
397
+ defaultName: `Style reference ${index + 1}`,
398
+ source: "manual",
399
+ url: s.url,
400
+ isExtraRef: true,
401
+ defaultRole: "style",
402
+ description: styleReferenceDescription(index, s.caption),
403
+ }
404
+ }
405
+
406
+ /** One assembly of `input` as given (no style-reference fill, no empty check). */
407
+ function assembleOnce(input: AssembleImageInput): BuildImagePromptResult {
408
+ const pieces = renderImageHintPieces(input.subject, input.direction, input.structured, input.directionRefs)
298
409
 
299
410
  const assembleWith = (keptHintClauses: number) => buildImagePromptWithOverflow({
300
411
  prompt: composePromptText(input.userPrompt, pieces, keptHintClauses),
@@ -359,15 +470,5 @@ export function assembleImageInput(
359
470
  }
360
471
  // `overflowChars` is assembly bookkeeping, not part of the callers' contract.
361
472
  const { overflowChars, ...result } = fitted
362
-
363
- // Post-assembly empty-prompt check (opt-in): a bound entity / `@`-mention /
364
- // subject or direction chip could have filled the assembled prompt even if the user
365
- // typed nothing — so only reject when the FINAL prompt is truly empty.
366
- if (input.throwOnEmpty && !result.prompt.trim()) {
367
- throw new Error(
368
- "No prompt — type one, bind a character, or pick a cinematography direction",
369
- )
370
- }
371
-
372
473
  return result
373
474
  }
@@ -1,5 +1,7 @@
1
1
  import { PICKER_CATALOGS, getPickerCatalog } from "./picker-catalogs.js"
2
2
  import { getRegisteredCatalogPacks, catalogPacksVersion } from "./catalog-packs.js"
3
+ import { userRefKindOfDirectionKey } from "./direction-registry.js"
4
+ import { parseUserDirectionRef } from "./user-direction-refs.js"
3
5
 
4
6
  /**
5
7
  * THE WALL: refuse work that names a catalog id this deployment does not
@@ -113,18 +115,32 @@ export function catalogGuardActive(): boolean {
113
115
  return getRegisteredCatalogPacks().length > 0
114
116
  }
115
117
 
118
+ /**
119
+ * A `direction` value that names the caller's OWN saved resource on the key
120
+ * that takes that kind (`colorLook: "palette:<id>"`, `style: "moodboard:<id>"`)
121
+ * is not a catalog id, so curation has nothing to say about it — the platform
122
+ * resolves it against the caller's rows instead. On any other key, and in a
123
+ * parameter node's own fields, the same value is still an unknown id.
124
+ */
125
+ function isOwnUserRef(field: string, id: string): boolean {
126
+ const ref = parseUserDirectionRef(id)
127
+ return ref !== null && userRefKindOfDirectionKey(field) === ref.kind
128
+ }
129
+
116
130
  function checkRecord(
117
131
  nodeType: string,
118
132
  nodeId: string | undefined,
119
133
  data: Record<string, unknown>,
120
134
  fields: ReadonlyMap<string, string>,
121
135
  out: ForeignCatalogId[],
136
+ skip?: (field: string, id: string) => boolean,
122
137
  ): void {
123
138
  for (const [field, catalogId] of fields) {
124
139
  const raw = data[field]
125
140
  if (raw === undefined || raw === null) continue
126
141
  const offered = composedIds(catalogId)
127
142
  for (const id of values(raw)) {
143
+ if (skip?.(field, id)) continue
128
144
  if (!offered.has(id)) out.push({ nodeId, nodeType, field, id, catalogId })
129
145
  }
130
146
  }
@@ -152,7 +168,7 @@ export function findForeignCatalogIds(
152
168
  for (const key of ["direction", "subject"] as const) {
153
169
  const rec = data[key]
154
170
  if (rec && typeof rec === "object" && !Array.isArray(rec)) {
155
- checkRecord(type, nodeId, rec as Record<string, unknown>, table.byField, out)
171
+ checkRecord(type, nodeId, rec as Record<string, unknown>, table.byField, out, key === "direction" ? isOwnUserRef : undefined)
156
172
  }
157
173
  }
158
174
  }
@@ -170,7 +186,7 @@ export function findForeignCatalogIdsInBody(
170
186
  for (const key of ["direction", "subject"] as const) {
171
187
  const rec = body[key]
172
188
  if (rec && typeof rec === "object" && !Array.isArray(rec)) {
173
- checkRecord(nodeType, undefined, rec as Record<string, unknown>, table.byField, out)
189
+ checkRecord(nodeType, undefined, rec as Record<string, unknown>, table.byField, out, key === "direction" ? isOwnUserRef : undefined)
174
190
  }
175
191
  }
176
192
  return out