@nodaro/prompts 1.38.0 → 1.39.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-LVSH7K7R.cjs → chunk-22KZOAGL.cjs} +81 -42
- package/dist/chunk-22KZOAGL.cjs.map +1 -0
- package/dist/chunk-2P26SZX5.cjs +414 -0
- package/dist/chunk-2P26SZX5.cjs.map +1 -0
- package/dist/{chunk-CVPB2N3Z.cjs → chunk-3INXNDV7.cjs} +102 -4
- package/dist/chunk-3INXNDV7.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-NO6XHX4R.js → chunk-6MPZHFIS.js} +99 -6
- package/dist/chunk-6MPZHFIS.js.map +1 -0
- package/dist/{chunk-4FDNVNJE.js → chunk-7XMTVYS5.js} +3 -76
- package/dist/chunk-7XMTVYS5.js.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-TLVX5PXD.cjs → chunk-IUCNJAHW.cjs} +13 -7
- package/dist/chunk-IUCNJAHW.cjs.map +1 -0
- package/dist/{chunk-ZFIXQRBZ.cjs → chunk-KUBZVWME.cjs} +2 -80
- package/dist/chunk-KUBZVWME.cjs.map +1 -0
- package/dist/{chunk-JKCR6WRZ.cjs → chunk-NMWKXAYA.cjs} +2 -2
- package/dist/{chunk-JKCR6WRZ.cjs.map → chunk-NMWKXAYA.cjs.map} +1 -1
- 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-MENBHEAY.cjs → chunk-WRM52ZBN.cjs} +19 -161
- package/dist/chunk-WRM52ZBN.cjs.map +1 -0
- package/dist/chunk-XIWWDGT5.cjs +86 -0
- package/dist/chunk-XIWWDGT5.cjs.map +1 -0
- package/dist/{chunk-CEPVQQVO.js → chunk-XRLOMTBZ.js} +2 -2
- package/dist/{chunk-CEPVQQVO.js.map → chunk-XRLOMTBZ.js.map} +1 -1
- package/dist/{chunk-QBEZMR2F.js → chunk-XU54YJXQ.js} +54 -15
- package/dist/chunk-XU54YJXQ.js.map +1 -0
- package/dist/chunk-Z3SZXWRU.js +80 -0
- package/dist/chunk-Z3SZXWRU.js.map +1 -0
- package/dist/entries/assembly.cjs +26 -25
- package/dist/entries/assembly.d.cts +32 -4
- package/dist/entries/assembly.d.ts +32 -4
- 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 +51 -1
- package/dist/entries/presets.d.ts +51 -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.js +2 -1
- package/dist/index.cjs +331 -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__/user-direction-refs.test.ts +180 -0
- 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/prompt-builder.ts +2 -0
- package/src/factory-presets/generate-image.ts +29 -0
- package/src/factory-presets/index.ts +2 -0
- package/src/factory-presets/preview-files.generated.ts +4 -0
- package/src/factory-presets/previews.ts +40 -0
- package/src/factory-presets/references.ts +80 -0
- package/src/factory-presets/shared-image.ts +4 -0
- package/src/factory-presets/types.ts +8 -0
- package/src/image-reference-doctrine.ts +3 -3
- package/src/image-token-pattern.ts +13 -0
- package/src/prompt-builder.ts +4 -5
- package/src/prompt-style-section.ts +3 -3
- package/src/user-direction-refs.ts +110 -0
- package/dist/chunk-4FDNVNJE.js.map +0 -1
- package/dist/chunk-7XKHDXCL.cjs.map +0 -1
- package/dist/chunk-CVPB2N3Z.cjs.map +0 -1
- package/dist/chunk-HRBBNODV.js.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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `assembleImageInput` with a caller's RESOLVED palette / moodboard:
|
|
3
|
+
*
|
|
4
|
+
* - `directionRefs` — the clause each user-owned direction id resolved to,
|
|
5
|
+
* folded in that row's slot like a catalog clause;
|
|
6
|
+
* - `styleReferences` — a moodboard's images. They only FILL the reference
|
|
7
|
+
* seats the caller's own references leave free, after them, and never move,
|
|
8
|
+
* renumber or evict one of the caller's own (the attach-priority cap would
|
|
9
|
+
* otherwise let a style image push out a mentioned character variant).
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from "vitest"
|
|
12
|
+
import { assembleImageInput } from "../assemble-image-input.js"
|
|
13
|
+
import { styleReferenceDescription } from "../user-direction-refs.js"
|
|
14
|
+
import { imageReferenceLimit } from "@nodaro/shared/models"
|
|
15
|
+
import type { ConnectedReference } from "@nodaro/shared/types"
|
|
16
|
+
|
|
17
|
+
const PALETTE = "palette:0f9e8d7c-6b5a-4c3d-9e2f-1a2b3c4d5e6f"
|
|
18
|
+
const MOODBOARD = "moodboard:0f9e8d7c-6b5a-4c3d-9e2f-1a2b3c4d5e6f"
|
|
19
|
+
const style = (n: number) =>
|
|
20
|
+
Array.from({ length: n }, (_, i) => ({ url: `https://cdn.test/mood-${i + 1}.png` }))
|
|
21
|
+
const flat = (n: number) => Array.from({ length: n }, (_, i) => `https://cdn.test/own-${i + 1}.png`)
|
|
22
|
+
|
|
23
|
+
const kira: ConnectedReference[] = [
|
|
24
|
+
{ id: "k0", defaultName: "Kira", source: "wired-character", url: "https://cdn.test/kira.png", characterSlug: "kira", characterCanonicalDescription: "a red-haired woman" },
|
|
25
|
+
{ id: "k1", defaultName: "Kira", source: "wired-character", url: "https://cdn.test/kira-smile.png", characterSlug: "kira", variantSlug: "smile" },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
describe("directionRefs", () => {
|
|
29
|
+
it("folds a resolved palette onto the [style] film line", () => {
|
|
30
|
+
const result = assembleImageInput({
|
|
31
|
+
userPrompt: "a cat on a sofa",
|
|
32
|
+
provider: "nano-banana-pro",
|
|
33
|
+
direction: { colorLook: PALETTE },
|
|
34
|
+
directionRefs: new Map([[PALETTE, "color palette: deep teal #0F4C5C"]]),
|
|
35
|
+
})
|
|
36
|
+
expect(result.prompt).toBe("a cat on a sofa\n\n[style]:\ncolor palette: deep teal #0F4C5C")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("folds nothing for an id it was not handed (the platform resolves before assembling)", () => {
|
|
40
|
+
const result = assembleImageInput({ userPrompt: "a cat", provider: "nano-banana-pro", direction: { style: MOODBOARD } })
|
|
41
|
+
expect(result.prompt).toBe("a cat")
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("styleReferences — absent is byte-identical", () => {
|
|
46
|
+
it("adds no field and changes nothing when none are given", () => {
|
|
47
|
+
const input = { userPrompt: "a cat", provider: "nano-banana-pro", extraReferenceImageUrls: flat(2) }
|
|
48
|
+
const result = assembleImageInput(input)
|
|
49
|
+
expect(result).toEqual(assembleImageInput({ ...input, styleReferences: undefined }))
|
|
50
|
+
expect("styleReferencesSent" in result).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("styleReferences — fill the free seats, after the caller's own", () => {
|
|
55
|
+
it("appends them after the caller's references and says how many went", () => {
|
|
56
|
+
for (const referenceFormat of ["legacy", "hybrid"] as const) {
|
|
57
|
+
const base = assembleImageInput({ userPrompt: "a cat", provider: "nano-banana-pro", extraReferenceImageUrls: flat(2), referenceFormat })
|
|
58
|
+
const result = assembleImageInput({
|
|
59
|
+
userPrompt: "a cat",
|
|
60
|
+
provider: "nano-banana-pro",
|
|
61
|
+
extraReferenceImageUrls: flat(2),
|
|
62
|
+
referenceFormat,
|
|
63
|
+
styleReferences: style(3),
|
|
64
|
+
})
|
|
65
|
+
expect(result.referenceImageUrls, referenceFormat).toEqual([...base.referenceImageUrls!, ...style(3).map((s) => s.url)])
|
|
66
|
+
expect(result.styleReferencesSent, referenceFormat).toBe(3)
|
|
67
|
+
expect(result.prompt, referenceFormat).toContain(styleReferenceDescription(0))
|
|
68
|
+
expect(result.prompt, referenceFormat).toContain(styleReferenceDescription(1))
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("never sends more than the model takes: the caller's references come first", () => {
|
|
73
|
+
const cap = imageReferenceLimit("nano-banana-pro")
|
|
74
|
+
const result = assembleImageInput({
|
|
75
|
+
userPrompt: "a cat",
|
|
76
|
+
provider: "nano-banana-pro",
|
|
77
|
+
extraReferenceImageUrls: flat(cap - 2),
|
|
78
|
+
styleReferences: style(5),
|
|
79
|
+
})
|
|
80
|
+
expect(result.referenceImageUrls).toEqual([...flat(cap - 2), ...style(2).map((s) => s.url)])
|
|
81
|
+
expect(result.styleReferencesSent).toBe(2)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("sends none when the caller already fills every seat", () => {
|
|
85
|
+
const cap = imageReferenceLimit("nano-banana-pro")
|
|
86
|
+
const result = assembleImageInput({
|
|
87
|
+
userPrompt: "a cat",
|
|
88
|
+
provider: "nano-banana-pro",
|
|
89
|
+
extraReferenceImageUrls: flat(cap),
|
|
90
|
+
styleReferences: style(2),
|
|
91
|
+
})
|
|
92
|
+
expect(result.referenceImageUrls).toEqual(flat(cap))
|
|
93
|
+
expect(result.styleReferencesSent).toBe(0)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("never evicts a mentioned character variant to make room", () => {
|
|
97
|
+
// cap 8; the caller's two connected entries (a canonical + the mentioned
|
|
98
|
+
// variant) send ONE image, so seven seats look free — but seven style
|
|
99
|
+
// images would push the raw list past the attach-priority cap, which
|
|
100
|
+
// keeps non-variants first and would drop the mentioned variant.
|
|
101
|
+
const base = assembleImageInput({
|
|
102
|
+
userPrompt: "@kira:2:smile walking",
|
|
103
|
+
provider: "nano-banana-pro",
|
|
104
|
+
connectedReferences: kira,
|
|
105
|
+
referenceFormat: "hybrid",
|
|
106
|
+
})
|
|
107
|
+
expect(base.referenceImageUrls).toEqual(["https://cdn.test/kira-smile.png"])
|
|
108
|
+
const result = assembleImageInput({
|
|
109
|
+
userPrompt: "@kira:2:smile walking",
|
|
110
|
+
provider: "nano-banana-pro",
|
|
111
|
+
connectedReferences: kira,
|
|
112
|
+
referenceFormat: "hybrid",
|
|
113
|
+
styleReferences: style(12),
|
|
114
|
+
})
|
|
115
|
+
expect(result.referenceImageUrls![0]).toBe("https://cdn.test/kira-smile.png")
|
|
116
|
+
expect(result.styleReferencesSent).toBe(6)
|
|
117
|
+
expect(result.referenceImageUrls).toEqual(["https://cdn.test/kira-smile.png", ...style(6).map((s) => s.url)])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("skips a style image the caller already sends, and fills with the next", () => {
|
|
121
|
+
const own = flat(1)
|
|
122
|
+
const result = assembleImageInput({
|
|
123
|
+
userPrompt: "a cat",
|
|
124
|
+
provider: "nano-banana-pro",
|
|
125
|
+
extraReferenceImageUrls: own,
|
|
126
|
+
styleReferences: [{ url: own[0] }, ...style(2)],
|
|
127
|
+
})
|
|
128
|
+
expect(result.referenceImageUrls).toEqual([own[0], ...style(2).map((s) => s.url)])
|
|
129
|
+
expect(result.styleReferencesSent).toBe(2)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("sends none to a model that takes no reference images", () => {
|
|
133
|
+
expect(imageReferenceLimit("imagen4")).toBe(0)
|
|
134
|
+
const result = assembleImageInput({ userPrompt: "a cat", provider: "imagen4", styleReferences: style(2) })
|
|
135
|
+
expect(result.referenceImageUrls ?? []).toEqual([])
|
|
136
|
+
expect(result.styleReferencesSent).toBe(0)
|
|
137
|
+
expect(result.prompt).toBe("a cat")
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("sends none on the trained-character (LoRA) path, which takes no references", () => {
|
|
141
|
+
const result = assembleImageInput({
|
|
142
|
+
userPrompt: "@kira walking",
|
|
143
|
+
provider: "nano-banana-pro",
|
|
144
|
+
connectedReferences: kira,
|
|
145
|
+
skipCharacterMentions: true,
|
|
146
|
+
styleReferences: style(2),
|
|
147
|
+
})
|
|
148
|
+
expect(result.referenceImageUrls ?? []).toEqual([])
|
|
149
|
+
expect(result.styleReferencesSent).toBe(0)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("carries each image's caption into its line", () => {
|
|
153
|
+
const result = assembleImageInput({
|
|
154
|
+
userPrompt: "a cat",
|
|
155
|
+
provider: "nano-banana-pro",
|
|
156
|
+
referenceFormat: "hybrid",
|
|
157
|
+
styleReferences: [{ url: "https://cdn.test/m.png", caption: "linen and oak" }],
|
|
158
|
+
})
|
|
159
|
+
expect(result.prompt).toContain(styleReferenceDescription(0, "linen and oak"))
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("an empty prompt with style images is not an empty prompt", () => {
|
|
163
|
+
const result = assembleImageInput({
|
|
164
|
+
userPrompt: "",
|
|
165
|
+
provider: "nano-banana-pro",
|
|
166
|
+
styleReferences: style(1),
|
|
167
|
+
throwOnEmpty: true,
|
|
168
|
+
})
|
|
169
|
+
expect(result.styleReferencesSent).toBe(1)
|
|
170
|
+
expect(result.prompt.trim().length).toBeGreaterThan(0)
|
|
171
|
+
expect(() => assembleImageInput({ userPrompt: "", provider: "nano-banana-pro", throwOnEmpty: true })).toThrow(/No prompt/)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
@@ -132,6 +132,36 @@ describe("the field contract covers the shapes the resolvers actually read", ()
|
|
|
132
132
|
})
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
+
describe("a reference to the caller's own palette / moodboard is not a catalog id", () => {
|
|
136
|
+
const PALETTE = "palette:0f9e8d7c-6b5a-4c3d-9e2f-1a2b3c4d5e6f"
|
|
137
|
+
const MOODBOARD = "moodboard:0f9e8d7c-6b5a-4c3d-9e2f-1a2b3c4d5e6f"
|
|
138
|
+
const curate = () => {
|
|
139
|
+
registerCatalogPack({ id: "g-color", catalogId: "color-look", mode: "deny", denyIds: [getPickerCatalog("color-look")!.options![0].id] })
|
|
140
|
+
registerCatalogPack({ id: "g-style", catalogId: "style", mode: "deny", denyIds: [getPickerCatalog("style")!.options![0].id] })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it("a curated deployment lets one through on its own key, in a request body", () => {
|
|
144
|
+
curate()
|
|
145
|
+
expect(findForeignCatalogIdsInBody("generate-image", { direction: { colorLook: PALETTE, style: MOODBOARD } })).toEqual([])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("and on a consumer node's stored direction (the graph lane)", () => {
|
|
149
|
+
curate()
|
|
150
|
+
expect(findForeignCatalogIds([{ id: "g", type: "generate-image", data: { direction: { colorLook: PALETTE, style: MOODBOARD } } }])).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("is still refused on a key that does not take that kind — it is no id there", () => {
|
|
154
|
+
curate()
|
|
155
|
+
expect(findForeignCatalogIdsInBody("generate-image", { direction: { colorLook: MOODBOARD } })).toHaveLength(1)
|
|
156
|
+
expect(findForeignCatalogIdsInBody("generate-image", { direction: { mood: PALETTE } })).toHaveLength(1)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("is still refused in a parameter node's own field (a picker never takes one)", () => {
|
|
160
|
+
curate()
|
|
161
|
+
expect(findForeignCatalogIds([{ id: "c", type: "color-look", data: { colorLook: PALETTE } }])).toHaveLength(1)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
135
165
|
describe("the message", () => {
|
|
136
166
|
it("names every offending value once, by field", () => {
|
|
137
167
|
const msg = foreignCatalogIdMessage([
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @test-watches frontend/public/preset-previews/**
|
|
2
|
+
/**
|
|
3
|
+
* Factory preset previews: one small example picture per preset, committed
|
|
4
|
+
* under frontend/public/preset-previews/<nodeType>/<slug>.<hash8>.webp and
|
|
5
|
+
* served by the app at /preset-previews/… (cached as immutable, so the name
|
|
6
|
+
* must carry the file's own content hash). The generated map
|
|
7
|
+
* (preview-files.generated.ts, written by tools/preset-previews/build.mts) is
|
|
8
|
+
* what the API reads to hand out `previewUrl`.
|
|
9
|
+
*
|
|
10
|
+
* The API must never point at a file that does not exist — the CDN in front
|
|
11
|
+
* keeps a 404 for a long time — so the map and the folder must agree exactly:
|
|
12
|
+
* - every mapped file exists, is non-empty and carries its own hash;
|
|
13
|
+
* - every mapped id is a factory preset, and the file sits under its node
|
|
14
|
+
* type with its slug as the name;
|
|
15
|
+
* - no file in the folder is missing from the map (a dropped-in file that
|
|
16
|
+
* was never built, or a stale one).
|
|
17
|
+
* With no previews at all (the folder absent) every check holds and every
|
|
18
|
+
* `previewUrl` is null.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto"
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
22
|
+
import { dirname, join } from "node:path"
|
|
23
|
+
import { fileURLToPath } from "node:url"
|
|
24
|
+
import { describe, expect, it } from "vitest"
|
|
25
|
+
import {
|
|
26
|
+
FACTORY_PRESETS,
|
|
27
|
+
FACTORY_PRESET_PREVIEW_FILES,
|
|
28
|
+
PRESET_PREVIEW_PATH,
|
|
29
|
+
factoryPresetPreviewUrl,
|
|
30
|
+
} from "../factory-presets.js"
|
|
31
|
+
|
|
32
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..")
|
|
33
|
+
const PREVIEW_DIR = join(REPO_ROOT, "frontend", "public", "preset-previews")
|
|
34
|
+
const FILE_NAME = /^([a-z0-9]+(?:-[a-z0-9]+)*)\.([0-9a-f]{8})\.webp$/
|
|
35
|
+
|
|
36
|
+
/** Every file under the folder, as `<nodeType>/<name>` (dotfiles skipped). */
|
|
37
|
+
function filesOnDisk(): string[] {
|
|
38
|
+
if (!existsSync(PREVIEW_DIR)) return []
|
|
39
|
+
return readdirSync(PREVIEW_DIR, { withFileTypes: true }).flatMap((entry) => {
|
|
40
|
+
if (entry.name.startsWith(".")) return []
|
|
41
|
+
if (!entry.isDirectory()) return [entry.name]
|
|
42
|
+
return readdirSync(join(PREVIEW_DIR, entry.name))
|
|
43
|
+
.filter((name) => !name.startsWith("."))
|
|
44
|
+
.map((name) => `${entry.name}/${name}`)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const factoryIds = new Set(Object.values(FACTORY_PRESETS).flatMap((list) => list.map((p) => p.id)))
|
|
49
|
+
|
|
50
|
+
describe("factory preset previews — the map and the folder agree", () => {
|
|
51
|
+
it("every mapped id is a factory preset, filed under its node type and slug", () => {
|
|
52
|
+
const wrong: string[] = []
|
|
53
|
+
for (const [id, file] of Object.entries(FACTORY_PRESET_PREVIEW_FILES)) {
|
|
54
|
+
if (!factoryIds.has(id)) wrong.push(`${id}: not a factory preset`)
|
|
55
|
+
const [nodeType, name] = file.split("/")
|
|
56
|
+
const match = FILE_NAME.exec(name ?? "")
|
|
57
|
+
const slug = id.slice(id.indexOf("/") + 1)
|
|
58
|
+
if (`${nodeType}/${match?.[1]}` !== `${id.slice(0, id.indexOf("/"))}/${slug}`) {
|
|
59
|
+
wrong.push(`${id}: filed as ${file}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
expect(wrong).toEqual([])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("every mapped file exists, is non-empty and is named by its own content hash", () => {
|
|
66
|
+
const wrong: string[] = []
|
|
67
|
+
for (const [id, file] of Object.entries(FACTORY_PRESET_PREVIEW_FILES)) {
|
|
68
|
+
const path = join(PREVIEW_DIR, file)
|
|
69
|
+
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
70
|
+
wrong.push(`${id}: ${file} is missing`)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
const bytes = readFileSync(path)
|
|
74
|
+
if (bytes.length === 0) wrong.push(`${id}: ${file} is empty`)
|
|
75
|
+
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 8)
|
|
76
|
+
if (FILE_NAME.exec(file.split("/")[1] ?? "")?.[2] !== hash) wrong.push(`${id}: ${file} does not carry its hash ${hash}`)
|
|
77
|
+
}
|
|
78
|
+
expect(wrong).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("no file in the folder is missing from the map (run tools/preset-previews/build.mts)", () => {
|
|
82
|
+
const mapped = new Set(Object.values(FACTORY_PRESET_PREVIEW_FILES))
|
|
83
|
+
expect(filesOnDisk().filter((f) => !mapped.has(f))).toEqual([])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("each file is mapped from exactly one preset", () => {
|
|
87
|
+
const files = Object.values(FACTORY_PRESET_PREVIEW_FILES)
|
|
88
|
+
expect(files.length).toBe(new Set(files).size)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("factoryPresetPreviewUrl", () => {
|
|
93
|
+
const FILES = { "generate-image/character-board": "generate-image/character-board.1a2b3c4d.webp" }
|
|
94
|
+
|
|
95
|
+
it("is an absolute URL on the installation's origin for a preset that has a preview", () => {
|
|
96
|
+
expect(factoryPresetPreviewUrl("generate-image/character-board", "https://app.nodaro.ai", FILES)).toBe(
|
|
97
|
+
"https://app.nodaro.ai/preset-previews/generate-image/character-board.1a2b3c4d.webp",
|
|
98
|
+
)
|
|
99
|
+
expect(factoryPresetPreviewUrl("generate-image/character-board", "http://localhost:3000/", FILES)).toBe(
|
|
100
|
+
"http://localhost:3000/preset-previews/generate-image/character-board.1a2b3c4d.webp",
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("is null for a preset without a preview", () => {
|
|
105
|
+
expect(factoryPresetPreviewUrl("generate-image/pose-board", "https://app.nodaro.ai", FILES)).toBeNull()
|
|
106
|
+
expect(factoryPresetPreviewUrl("not-a/preset", "https://app.nodaro.ai", FILES)).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("is null when the base URL is unusable, rather than a broken link", () => {
|
|
110
|
+
for (const base of ["", "not a url", "javascript:alert(1)", "ftp://host"]) {
|
|
111
|
+
expect(factoryPresetPreviewUrl("generate-image/character-board", base, FILES)).toBeNull()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("reads the committed map by default, and every committed preview resolves under /preset-previews/", () => {
|
|
116
|
+
for (const id of Object.keys(FACTORY_PRESET_PREVIEW_FILES)) {
|
|
117
|
+
expect(factoryPresetPreviewUrl(id, "https://app.nodaro.ai")).toMatch(
|
|
118
|
+
new RegExp(`^https://app\\.nodaro\\.ai${PRESET_PREVIEW_PATH}[a-z0-9-]+/[a-z0-9-]+\\.[0-9a-f]{8}\\.webp$`),
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
expect(factoryPresetPreviewUrl("not-a/preset", "https://app.nodaro.ai")).toBeNull()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many connected reference images a preset works from (`minReferences`,
|
|
3
|
+
* served as `needsImage` / `minReferences` by `GET /v1/node-presets/factory`
|
|
4
|
+
* and on every user preset).
|
|
5
|
+
*
|
|
6
|
+
* Two sources feed one number (`presetMinReferences`):
|
|
7
|
+
* - the `{image:N}` / `{image:N:label}` tokens in the preset's prompt fields —
|
|
8
|
+
* derived, so a template that names its references can never under-report;
|
|
9
|
+
* - a DECLARATION on the factory preset, for the families whose prompt says
|
|
10
|
+
* "the attached photo" in words (Reference Sheet, Portrait Transformations,
|
|
11
|
+
* Stylized Subject, Edits, …): no data field marks those, so the number is
|
|
12
|
+
* stated where the preset is written.
|
|
13
|
+
*
|
|
14
|
+
* The guards below keep the declarations honest: a declaration never states
|
|
15
|
+
* less than the preset's own tokens, a group never mixes photo and text-only
|
|
16
|
+
* presets unless it is listed with a reason, and the families the product
|
|
17
|
+
* names as photo-based all resolve to at least one reference.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect } from "vitest"
|
|
20
|
+
import { FACTORY_PRESETS, MAX_PRESET_REFERENCES, presetMinReferences, type FactoryPreset } from "../factory-presets.js"
|
|
21
|
+
import { NODE_PROMPT_CANDIDATE_FIELDS, computeNodePrompt } from "../resolve-prompt.js"
|
|
22
|
+
import { nodeSupportsPromptAffixes } from "../node-prompt-fields.js"
|
|
23
|
+
|
|
24
|
+
const all = (): Array<[nodeType: string, preset: FactoryPreset]> =>
|
|
25
|
+
Object.entries(FACTORY_PRESETS).flatMap(([nodeType, list]) => list.map((p) => [nodeType, p] as [string, FactoryPreset]))
|
|
26
|
+
|
|
27
|
+
const effective = (nodeType: string, p: FactoryPreset): number => presetMinReferences(nodeType, p.data, p.minReferences)
|
|
28
|
+
const tokenFloor = (nodeType: string, p: FactoryPreset): number => presetMinReferences(nodeType, p.data)
|
|
29
|
+
|
|
30
|
+
describe("presetMinReferences — derived from the prompt's reference tokens", () => {
|
|
31
|
+
it("is 0 for a prompt with no tokens, and for data with no prompt at all", () => {
|
|
32
|
+
expect(presetMinReferences("generate-image", { prompt: "a red fox in the snow" })).toBe(0)
|
|
33
|
+
expect(presetMinReferences("generate-image", {})).toBe(0)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("reads the highest position over bare and labelled tokens", () => {
|
|
37
|
+
expect(presetMinReferences("generate-image", { prompt: "make the persons in {image:1} faceless" })).toBe(1)
|
|
38
|
+
expect(presetMinReferences("generate-image", { prompt: "{image:1:person} holds {image:2:product}" })).toBe(2)
|
|
39
|
+
expect(presetMinReferences("generate-image", { prompt: "{image:3} beside {image:1:dog}" })).toBe(3)
|
|
40
|
+
expect(presetMinReferences("generate-video", { prompt: "{IMAGE:2:product} spins" })).toBe(2)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("reads the pre/post text and the negative prompt, where the node has them", () => {
|
|
44
|
+
expect(presetMinReferences("generate-image", { promptPrefix: "keep {image:1} unchanged" })).toBe(1)
|
|
45
|
+
expect(presetMinReferences("generate-image", { promptSuffix: "lit like {image:2:settings}" })).toBe(2)
|
|
46
|
+
expect(presetMinReferences("generate-image", { negativePrompt: "anything unlike {image:1:face}" })).toBe(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("ignores fields that are not the node's prompt fields, and values that are not text", () => {
|
|
50
|
+
expect(presetMinReferences("generate-image", { style: "{image:4}", prompt: "a cat" })).toBe(0)
|
|
51
|
+
expect(presetMinReferences("generate-image", { prompt: 42 as unknown as string })).toBe(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("reads a node whose prompt lives in another field (llm-chat → userInput)", () => {
|
|
55
|
+
expect(presetMinReferences("llm-chat", { userInput: "describe {image:1}" })).toBe(1)
|
|
56
|
+
expect(presetMinReferences("llm-chat", { prompt: "describe {image:1}" })).toBe(0)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("is 0 for a node type with no prompt fields (an app namespace such as recast-render)", () => {
|
|
60
|
+
expect(presetMinReferences("recast-render", { schemaVersion: 1, prompt: "{image:2}" })).toBe(0)
|
|
61
|
+
expect(presetMinReferences("does-not-exist", { prompt: "{image:2}" })).toBe(0)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("never counts position 0 (positions start at 1)", () => {
|
|
65
|
+
expect(presetMinReferences("generate-image", { prompt: "{image:0}" })).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("takes a declaration when it is higher than the tokens, and never goes below them", () => {
|
|
69
|
+
expect(presetMinReferences("generate-image", { prompt: "the attached photo" }, 1)).toBe(1)
|
|
70
|
+
expect(presetMinReferences("generate-image", { prompt: "{image:2}" }, 1)).toBe(2)
|
|
71
|
+
expect(presetMinReferences("generate-image", { prompt: "a cat" }, 0)).toBe(0)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("never asks for more photos than any image model takes", () => {
|
|
75
|
+
expect(MAX_PRESET_REFERENCES).toBeGreaterThanOrEqual(4)
|
|
76
|
+
expect(MAX_PRESET_REFERENCES).toBeLessThan(100)
|
|
77
|
+
expect(presetMinReferences("generate-image", { prompt: "{image:99999}" })).toBe(MAX_PRESET_REFERENCES)
|
|
78
|
+
expect(presetMinReferences("generate-image", { prompt: "a cat" }, 5000)).toBe(MAX_PRESET_REFERENCES)
|
|
79
|
+
expect(presetMinReferences("generate-image", { prompt: `{image:${MAX_PRESET_REFERENCES}}` })).toBe(MAX_PRESET_REFERENCES)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("ignores a declaration that is not a positive whole number", () => {
|
|
83
|
+
for (const bad of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
84
|
+
expect(presetMinReferences("generate-image", { prompt: "a cat" }, bad)).toBe(0)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One source of truth with how a preset RUNS: the run resolves a node's prompt
|
|
91
|
+
* through `computeNodePrompt` (the editor's build, and `presetId` on
|
|
92
|
+
* /v1/generate-image — lib/presets/apply-image-preset.ts), which joins the
|
|
93
|
+
* node's candidate prompt fields and its pre/post text. A token in any field
|
|
94
|
+
* the run reads must be counted, or a preset would run with fewer photos than
|
|
95
|
+
* its prompt names.
|
|
96
|
+
*/
|
|
97
|
+
describe("presetMinReferences reads every field the run reads", () => {
|
|
98
|
+
const cases = Object.entries(NODE_PROMPT_CANDIDATE_FIELDS).flatMap(([nodeType, fields]) => [
|
|
99
|
+
...fields.map((f) => [nodeType, f] as const),
|
|
100
|
+
...(nodeSupportsPromptAffixes(nodeType) ? (["promptPrefix", "promptSuffix"] as const).map((f) => [nodeType, f] as const) : []),
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
it.each(cases)("%s: a token in %s counts", (nodeType, field) => {
|
|
104
|
+
expect(presetMinReferences(nodeType, { [field]: "keep {image:3:subject} as is" })).toBe(3)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("counts the same tokens in a factory preset's raw data as in the prompt the run assembles from it", () => {
|
|
108
|
+
for (const [nodeType, p] of all()) {
|
|
109
|
+
if (!(nodeType in NODE_PROMPT_CANDIDATE_FIELDS)) continue
|
|
110
|
+
const assembled = computeNodePrompt(nodeType, { ...p.data }, { refMap: new Map() })
|
|
111
|
+
const fromRun = presetMinReferences(nodeType, {
|
|
112
|
+
prompt: assembled,
|
|
113
|
+
negativePrompt: typeof p.data.negativePrompt === "string" ? p.data.negativePrompt : undefined,
|
|
114
|
+
})
|
|
115
|
+
expect(tokenFloor(nodeType, p), p.id).toBe(fromRun)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe("factory presets — declared reference counts", () => {
|
|
121
|
+
it("a declaration is a positive whole number (absence means: no photo needed beyond the tokens)", () => {
|
|
122
|
+
const bad = all()
|
|
123
|
+
.filter(([, p]) => p.minReferences !== undefined && !(Number.isInteger(p.minReferences) && p.minReferences > 0))
|
|
124
|
+
.map(([, p]) => `${p.id}: ${p.minReferences}`)
|
|
125
|
+
expect(bad).toEqual([])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("no preset declares fewer references than its own prompt tokens name", () => {
|
|
129
|
+
const under = all()
|
|
130
|
+
.filter(([nodeType, p]) => p.minReferences !== undefined && p.minReferences < tokenFloor(nodeType, p))
|
|
131
|
+
.map(([nodeType, p]) => `${p.id}: declares ${p.minReferences}, its tokens name ${tokenFloor(nodeType, p)}`)
|
|
132
|
+
expect(under).toEqual([])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("no preset declares exactly what its tokens already say (the declaration is for prose, not tokens)", () => {
|
|
136
|
+
const redundant = all()
|
|
137
|
+
.filter(([nodeType, p]) => p.minReferences !== undefined && p.minReferences === tokenFloor(nodeType, p))
|
|
138
|
+
.map(([, p]) => p.id)
|
|
139
|
+
expect(redundant).toEqual([])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Groups whose presets mix photo-based and text-only entries on purpose.
|
|
144
|
+
* Every other group is all-or-nothing, so a preset added to a photo group
|
|
145
|
+
* without its declaration fails here instead of shipping as text-only.
|
|
146
|
+
*/
|
|
147
|
+
const MIXED_GROUPS: Readonly<Record<string, string>> = {
|
|
148
|
+
"generate-image/Realism & Authentic":
|
|
149
|
+
"text-only realism shots beside two that put the user's own photo in the frame ({image:1:person}, {image:1:product})",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
it("every group is all photo-based or all text-only, except the listed mixed groups", () => {
|
|
153
|
+
const groups = new Map<string, { photo: string[]; text: string[] }>()
|
|
154
|
+
for (const [nodeType, p] of all()) {
|
|
155
|
+
if (!p.group) continue
|
|
156
|
+
const key = `${nodeType}/${p.group}`
|
|
157
|
+
const g = groups.get(key) ?? { photo: [], text: [] }
|
|
158
|
+
;(effective(nodeType, p) > 0 ? g.photo : g.text).push(p.id)
|
|
159
|
+
groups.set(key, g)
|
|
160
|
+
}
|
|
161
|
+
const mixed = [...groups.entries()]
|
|
162
|
+
.filter(([key, g]) => g.photo.length > 0 && g.text.length > 0 && !(key in MIXED_GROUPS))
|
|
163
|
+
.map(([key, g]) => `${key}: photo [${g.photo.join(", ")}] vs text-only [${g.text.join(", ")}]`)
|
|
164
|
+
expect(mixed).toEqual([])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("each listed mixed group is still mixed (an entry that stopped mixing leaves the list)", () => {
|
|
168
|
+
for (const key of Object.keys(MIXED_GROUPS)) {
|
|
169
|
+
const [nodeType, ...rest] = key.split("/")
|
|
170
|
+
const group = rest.join("/")
|
|
171
|
+
const presets = (FACTORY_PRESETS[nodeType!] ?? []).filter((p) => p.group === group)
|
|
172
|
+
const photo = presets.filter((p) => effective(nodeType!, p) > 0).length
|
|
173
|
+
expect(photo, key).toBeGreaterThan(0)
|
|
174
|
+
expect(presets.length - photo, key).toBeGreaterThan(0)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
/** The families the product names as working from a connected photo. */
|
|
179
|
+
const PHOTO_FAMILIES: ReadonlyArray<[nodeType: string, group: string]> = [
|
|
180
|
+
["generate-image", "Reference Sheet"],
|
|
181
|
+
["generate-image", "Cast & Consistency"],
|
|
182
|
+
["generate-image", "Edit by Name"],
|
|
183
|
+
["generate-image", "Face Privacy"],
|
|
184
|
+
["generate-image", "Edits"],
|
|
185
|
+
["generate-image", "Portrait Transformations"],
|
|
186
|
+
["generate-image", "Stylized Subject"],
|
|
187
|
+
["modify-image", "Edits"],
|
|
188
|
+
["modify-image", "Stylized Subject"],
|
|
189
|
+
["generate-video", "UGC & Creator"],
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
it.each(PHOTO_FAMILIES)("%s · %s: every preset needs at least one reference", (nodeType, group) => {
|
|
193
|
+
const presets = (FACTORY_PRESETS[nodeType] ?? []).filter((p) => p.group === group)
|
|
194
|
+
expect(presets.length).toBeGreaterThan(0)
|
|
195
|
+
expect(presets.filter((p) => effective(nodeType, p) < 1).map((p) => p.id)).toEqual([])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("every preset whose prompt uses {image:N} needs at least N references", () => {
|
|
199
|
+
const short = all()
|
|
200
|
+
.filter(([nodeType, p]) => effective(nodeType, p) < tokenFloor(nodeType, p))
|
|
201
|
+
.map(([, p]) => p.id)
|
|
202
|
+
expect(short).toEqual([])
|
|
203
|
+
expect(all().filter(([nodeType, p]) => tokenFloor(nodeType, p) > 0).length).toBeGreaterThan(5)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it.each([
|
|
207
|
+
["generate-image", "generate-image/cast-mega-grid", 2],
|
|
208
|
+
["generate-image", "generate-image/character-board", 1],
|
|
209
|
+
["generate-image", "generate-image/faceless-3d-head", 1],
|
|
210
|
+
["generate-image", "generate-image/product-in-hand", 1],
|
|
211
|
+
["generate-image", "generate-image/cinematic-portrait", 0],
|
|
212
|
+
["modify-image", "modify-image/background-remove", 1],
|
|
213
|
+
["generate-video", "generate-video/ugc-product-closer", 2],
|
|
214
|
+
["generate-video", "generate-video/handheld-unboxing", 2],
|
|
215
|
+
["generate-video", "generate-video/ugc-talking-head", 1],
|
|
216
|
+
["generate-video", "generate-video/slow-push-in", 0],
|
|
217
|
+
] as const)("%s %s → %i", (nodeType, id, n) => {
|
|
218
|
+
const preset = (FACTORY_PRESETS[nodeType] ?? []).find((p) => p.id === id)
|
|
219
|
+
expect(preset, id).toBeDefined()
|
|
220
|
+
expect(effective(nodeType, preset!)).toBe(n)
|
|
221
|
+
})
|
|
222
|
+
})
|