@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.
- package/dist/chunk-2EW5B7ZV.cjs +86 -0
- package/dist/chunk-2EW5B7ZV.cjs.map +1 -0
- package/dist/chunk-2P26SZX5.cjs +414 -0
- package/dist/chunk-2P26SZX5.cjs.map +1 -0
- package/dist/{chunk-7XKHDXCL.cjs → chunk-3U7X6JZN.cjs} +13 -12
- package/dist/chunk-3U7X6JZN.cjs.map +1 -0
- package/dist/chunk-4GKTOVFM.cjs +8 -0
- package/dist/chunk-4GKTOVFM.cjs.map +1 -0
- package/dist/{chunk-HRBBNODV.js → chunk-6GVFJLDL.js} +4 -3
- package/dist/chunk-6GVFJLDL.js.map +1 -0
- package/dist/{chunk-LVSH7K7R.cjs → chunk-C3NHNECH.cjs} +165 -88
- package/dist/chunk-C3NHNECH.cjs.map +1 -0
- package/dist/{chunk-CEPVQQVO.js → chunk-C46256GZ.js} +43 -5
- package/dist/chunk-C46256GZ.js.map +1 -0
- package/dist/{chunk-JKCR6WRZ.cjs → chunk-DQFQJECU.cjs} +43 -5
- package/dist/chunk-DQFQJECU.cjs.map +1 -0
- package/dist/{chunk-QDEWBBUR.js → chunk-DUXX6CBX.js} +12 -143
- package/dist/chunk-DUXX6CBX.js.map +1 -0
- package/dist/chunk-EAYEZXWT.js +388 -0
- package/dist/chunk-EAYEZXWT.js.map +1 -0
- package/dist/chunk-GILUVQQJ.js +6 -0
- package/dist/chunk-GILUVQQJ.js.map +1 -0
- package/dist/{chunk-KIYNOOPJ.js → chunk-GTDKDD7Y.js} +12 -6
- package/dist/chunk-GTDKDD7Y.js.map +1 -0
- package/dist/{chunk-QBEZMR2F.js → chunk-HQTHDTHN.js} +137 -62
- package/dist/chunk-HQTHDTHN.js.map +1 -0
- package/dist/{chunk-TLVX5PXD.cjs → chunk-IUCNJAHW.cjs} +13 -7
- package/dist/chunk-IUCNJAHW.cjs.map +1 -0
- package/dist/{chunk-ZFIXQRBZ.cjs → chunk-LXPKTI2K.cjs} +12 -83
- package/dist/chunk-LXPKTI2K.cjs.map +1 -0
- package/dist/{chunk-CVPB2N3Z.cjs → chunk-LZ53F5BN.cjs} +103 -5
- package/dist/chunk-LZ53F5BN.cjs.map +1 -0
- package/dist/{chunk-4FDNVNJE.js → chunk-N6VMPG5S.js} +12 -79
- package/dist/chunk-N6VMPG5S.js.map +1 -0
- package/dist/{chunk-LK2CZK5U.cjs → chunk-PWWM7Q3T.cjs} +7 -7
- package/dist/{chunk-LK2CZK5U.cjs.map → chunk-PWWM7Q3T.cjs.map} +1 -1
- package/dist/{chunk-N7TOWXDJ.js → chunk-QDUTMOF4.js} +3 -3
- package/dist/{chunk-N7TOWXDJ.js.map → chunk-QDUTMOF4.js.map} +1 -1
- package/dist/{chunk-NO6XHX4R.js → chunk-RB2F5JQF.js} +100 -7
- package/dist/chunk-RB2F5JQF.js.map +1 -0
- package/dist/{chunk-MENBHEAY.cjs → chunk-WRM52ZBN.cjs} +19 -161
- package/dist/chunk-WRM52ZBN.cjs.map +1 -0
- package/dist/chunk-YS6HOWMW.js +80 -0
- package/dist/chunk-YS6HOWMW.js.map +1 -0
- package/dist/entries/assembly.cjs +34 -25
- package/dist/entries/assembly.d.cts +121 -37
- package/dist/entries/assembly.d.ts +121 -37
- package/dist/entries/assembly.js +5 -4
- package/dist/entries/doctrine.cjs +46 -46
- package/dist/entries/doctrine.d.cts +3 -3
- package/dist/entries/doctrine.d.ts +3 -3
- package/dist/entries/doctrine.js +1 -1
- package/dist/entries/picker-catalogs.cjs +42 -42
- package/dist/entries/picker-catalogs.js +3 -3
- package/dist/entries/presets.cjs +49 -26
- package/dist/entries/presets.d.cts +53 -1
- package/dist/entries/presets.d.ts +53 -1
- package/dist/entries/presets.js +4 -1
- package/dist/entries/prompt-builder.cjs +135 -98
- package/dist/entries/prompt-builder.d.cts +22 -11
- package/dist/entries/prompt-builder.d.ts +22 -11
- package/dist/entries/prompt-builder.js +4 -3
- package/dist/entries/prompt-fields.cjs +41 -40
- package/dist/entries/prompt-fields.d.cts +2 -1
- package/dist/entries/prompt-fields.d.ts +2 -1
- package/dist/entries/prompt-fields.js +2 -1
- package/dist/index.cjs +339 -273
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +11 -9
- package/dist/{subject-registry-BUswuEVD.d.cts → subject-registry-CQi_CZfJ.d.cts} +78 -9
- package/dist/{subject-registry-D7V8PEG-.d.ts → subject-registry-DFiGx49v.d.ts} +78 -9
- package/package.json +2 -2
- package/src/__tests__/assemble-image-input-style-references.test.ts +173 -0
- package/src/__tests__/catalog-id-guard.test.ts +30 -0
- package/src/__tests__/factory-preset-previews.test.ts +123 -0
- package/src/__tests__/factory-preset-references.test.ts +222 -0
- package/src/__tests__/factory-presets-prototype-keys.test.ts +43 -0
- package/src/__tests__/frame-reference-slots.test.ts +43 -0
- package/src/__tests__/gemini-omni-inputs.test.ts +12 -10
- package/src/__tests__/provider-prompt-doctrine.test.ts +14 -0
- package/src/__tests__/user-direction-refs.test.ts +180 -0
- package/src/__tests__/veo-i2v-inputs.test.ts +16 -11
- package/src/assemble-image-input.ts +114 -13
- package/src/catalog-id-guard.ts +18 -2
- package/src/color-names.ts +192 -0
- package/src/direction-registry.ts +66 -5
- package/src/entries/assembly.ts +1 -0
- package/src/entries/prompt-builder.ts +2 -0
- package/src/factory-presets/generate-image.ts +29 -0
- package/src/factory-presets/index.ts +6 -1
- package/src/factory-presets/preview-files.generated.ts +4 -0
- package/src/factory-presets/previews.ts +40 -0
- package/src/factory-presets/references.ts +81 -0
- package/src/factory-presets/shared-image.ts +4 -0
- package/src/factory-presets/types.ts +8 -0
- package/src/frame-reference-slots.ts +92 -0
- package/src/gemini-omni-inputs.ts +23 -22
- package/src/image-reference-doctrine.ts +3 -3
- package/src/image-token-pattern.ts +13 -0
- package/src/node-prompt-fields.ts +5 -3
- package/src/own-entry.ts +11 -0
- package/src/prompt-builder.ts +4 -5
- package/src/prompt-style-section.ts +3 -3
- package/src/prompt-wizard-categories.ts +2 -0
- package/src/provider-prompt-doctrine.ts +45 -3
- package/src/ref-binding.ts +28 -3
- package/src/resolve-prompt.ts +2 -1
- package/src/seedance-2-inputs.ts +15 -40
- package/src/user-direction-refs.ts +110 -0
- package/src/veo-i2v-inputs.ts +24 -17
- package/src/video-reference-resolver.ts +1 -1
- package/dist/chunk-4FDNVNJE.js.map +0 -1
- package/dist/chunk-7XKHDXCL.cjs.map +0 -1
- package/dist/chunk-CEPVQQVO.js.map +0 -1
- package/dist/chunk-CVPB2N3Z.cjs.map +0 -1
- package/dist/chunk-HRBBNODV.js.map +0 -1
- package/dist/chunk-JKCR6WRZ.cjs.map +0 -1
- package/dist/chunk-KIYNOOPJ.js.map +0 -1
- package/dist/chunk-LVSH7K7R.cjs.map +0 -1
- package/dist/chunk-MENBHEAY.cjs.map +0 -1
- package/dist/chunk-NO6XHX4R.js.map +0 -1
- package/dist/chunk-QBEZMR2F.js.map +0 -1
- package/dist/chunk-QDEWBBUR.js.map +0 -1
- package/dist/chunk-TLVX5PXD.cjs.map +0 -1
- package/dist/chunk-UJKFHPZ7.js +0 -37
- package/dist/chunk-UJKFHPZ7.js.map +0 -1
- package/dist/chunk-W5SCZIOB.cjs +0 -43
- package/dist/chunk-W5SCZIOB.cjs.map +0 -1
- 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:
|
|
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([
|
|
18
|
+
expect(r.imageUrls).toEqual([...refs(3), FIRST])
|
|
19
19
|
expect(r.promptSuffix).toBe(
|
|
20
|
-
"Use @
|
|
21
|
-
"@
|
|
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("@
|
|
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
|
|
41
|
-
expect(r.imageUrls
|
|
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("@
|
|
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([
|
|
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
|
|
10
|
-
*
|
|
11
|
-
* frames
|
|
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
|
|
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([
|
|
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 @
|
|
34
|
-
"@
|
|
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
|
|
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([
|
|
41
|
-
expect(r.
|
|
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("@
|
|
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)
|
|
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
|
-
):
|
|
297
|
-
const
|
|
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
|
}
|
package/src/catalog-id-guard.ts
CHANGED
|
@@ -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
|