@nodaro/prompts 1.8.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +371 -104
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +538 -64
- package/dist/index.d.ts +538 -64
- package/dist/index.js +353 -106
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/character-fx-timing-catalogs.test.ts +240 -0
- package/src/__tests__/factory-presets.test.ts +62 -12
- package/src/__tests__/node-prompt-fields.test.ts +81 -0
- package/src/__tests__/parameter-registry-sync.test.ts +4 -1
- package/src/__tests__/prompt-affixes.test.ts +83 -0
- package/src/__tests__/transition-timing-catalogs.test.ts +153 -0
- package/src/__tests__/video-reference-ref-id-tokens.test.ts +301 -0
- package/src/character-fx.ts +102 -19
- package/src/composition-effects.ts +6 -1
- package/src/factory-presets/generate-image.ts +37 -24
- package/src/factory-presets/shared-image.ts +10 -10
- package/src/factory-presets/switchx.ts +6 -6
- package/src/factory-presets/video-edit.ts +6 -6
- package/src/index.ts +1 -0
- package/src/node-prompt-fields.ts +206 -0
- package/src/picker-catalogs.ts +66 -17
- package/src/picker-wiring.ts +2 -1
- package/src/provider-prompt-doctrine.ts +2 -2
- package/src/ref-binding.ts +45 -0
- package/src/ref-id-tokens.ts +112 -0
- package/src/resolve-prompt.ts +60 -5
- package/src/transitions.ts +79 -19
- package/src/video-reference-resolver.ts +78 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodaro/prompts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "vitest run"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@nodaro/shared": "^2.
|
|
23
|
+
"@nodaro/shared": "^2.14.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.5.0",
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The character-fx node's three timing parameters (position / duration /
|
|
3
|
+
* intensity) are enumerable catalogs, not free values, so a consumer that can
|
|
4
|
+
* only send ids — Studio, the SDK, MCP — can offer them without composing any
|
|
5
|
+
* prompt text of its own. This is the character-fx twin of
|
|
6
|
+
* `transition-timing-catalogs.test.ts`; the two nodes share ids and shape but
|
|
7
|
+
* NOT wording, and nothing here may couple them.
|
|
8
|
+
*
|
|
9
|
+
* Three properties matter, and each is easy to lose silently:
|
|
10
|
+
*
|
|
11
|
+
* 1. The clause the composer INJECTS and the hint the catalog ADVERTISES are
|
|
12
|
+
* the same string. They are derived from one array in `character-fx.ts`,
|
|
13
|
+
* and these tests fail the moment someone re-introduces a second copy.
|
|
14
|
+
* 2. The prompt text is exactly what shipped before it was enumerable. The
|
|
15
|
+
* parameter-hint golden covers only three of the twelve clauses, so the
|
|
16
|
+
* twelve literals are pinned here: a change is a deliberate diff, never a
|
|
17
|
+
* side effect.
|
|
18
|
+
* 3. The dimensions survive the wire projection — `projectAllCatalogs()` is
|
|
19
|
+
* what an id-only client actually reads, and a green in-memory catalog
|
|
20
|
+
* proves nothing about it.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect } from "vitest"
|
|
23
|
+
import {
|
|
24
|
+
CHARACTER_FX_POSITIONS,
|
|
25
|
+
CHARACTER_FX_DURATIONS,
|
|
26
|
+
CHARACTER_FX_INTENSITIES,
|
|
27
|
+
composeCharacterFxHintFromConnections,
|
|
28
|
+
} from "../character-fx.js"
|
|
29
|
+
import { TRANSITION_POSITIONS, TRANSITION_DURATIONS } from "../transitions.js"
|
|
30
|
+
import { PICKER_CATALOGS, projectPickerCatalog, projectAllCatalogs } from "../picker-catalogs.js"
|
|
31
|
+
|
|
32
|
+
const DIMENSIONS = [
|
|
33
|
+
["position", CHARACTER_FX_POSITIONS],
|
|
34
|
+
["duration", CHARACTER_FX_DURATIONS],
|
|
35
|
+
["intensity", CHARACTER_FX_INTENSITIES],
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
const characterFxCatalog = PICKER_CATALOGS.find((c) => c.nodeType === "character-fx")!
|
|
39
|
+
|
|
40
|
+
describe("character-fx timing catalogs", () => {
|
|
41
|
+
// The `CharacterFxPosition` / `Duration` / `Intensity` unions are DERIVED
|
|
42
|
+
// from these arrays, so nothing at the type level can pin the values
|
|
43
|
+
// themselves. These are the values the editor dropdowns, the node data, and
|
|
44
|
+
// every stored workflow already use: changing one is a data migration, not a
|
|
45
|
+
// rename.
|
|
46
|
+
it("spells exactly the ids every stored workflow already uses", () => {
|
|
47
|
+
expect(CHARACTER_FX_POSITIONS.map((o) => o.id)).toEqual([
|
|
48
|
+
"auto", "start", "middle", "end", "full",
|
|
49
|
+
])
|
|
50
|
+
expect(CHARACTER_FX_DURATIONS.map((o) => o.id)).toEqual([
|
|
51
|
+
"auto", "instant", "short", "medium", "long",
|
|
52
|
+
])
|
|
53
|
+
expect(CHARACTER_FX_INTENSITIES.map((o) => o.id)).toEqual([
|
|
54
|
+
"auto", "subtle", "natural", "dynamic", "crazy",
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("injects exactly the clauses that shipped before the scales were enumerable", () => {
|
|
59
|
+
// Byte-for-byte the literals the hand-written clause tables carried. This
|
|
60
|
+
// task was exposure, not authoring: if any of these move, that is a prompt
|
|
61
|
+
// change for real users and must be its own deliberate diff.
|
|
62
|
+
expect(CHARACTER_FX_POSITIONS.map((o) => o.promptHint)).toEqual([
|
|
63
|
+
"",
|
|
64
|
+
"the effect occurs at the opening of the clip",
|
|
65
|
+
"the effect occurs in the middle of the clip",
|
|
66
|
+
"the effect occurs at the end of the clip",
|
|
67
|
+
"the effect persists for the entire clip",
|
|
68
|
+
])
|
|
69
|
+
expect(CHARACTER_FX_DURATIONS.map((o) => o.promptHint)).toEqual([
|
|
70
|
+
"",
|
|
71
|
+
"manifesting instantaneously",
|
|
72
|
+
"manifesting over approximately 1 second",
|
|
73
|
+
"manifesting over approximately 2 seconds",
|
|
74
|
+
"manifesting over approximately 3 seconds",
|
|
75
|
+
])
|
|
76
|
+
expect(CHARACTER_FX_INTENSITIES.map((o) => o.promptHint)).toEqual([
|
|
77
|
+
"",
|
|
78
|
+
"with subtle restrained energy and minimal flourish",
|
|
79
|
+
"with natural unhurried timing",
|
|
80
|
+
"with dynamic energy and assertive flourish",
|
|
81
|
+
"with extreme exaggerated energy, wild flourishes, and dramatic distortion",
|
|
82
|
+
])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("keeps its own wording — an effect manifests and persists, a transition occurs and spans", () => {
|
|
86
|
+
// The one constraint that matters most: these scales must never be unified
|
|
87
|
+
// with the transition ones. Position and duration are worded differently
|
|
88
|
+
// on purpose, so pointing character-fx at the transition rows fails here.
|
|
89
|
+
// Intensity is deliberately NOT compared: its three clauses happen to be
|
|
90
|
+
// identical today, and that is a coincidence, not a shared definition.
|
|
91
|
+
expect(CHARACTER_FX_POSITIONS.map((o) => o.promptHint)).not.toEqual(
|
|
92
|
+
TRANSITION_POSITIONS.map((o) => o.promptHint),
|
|
93
|
+
)
|
|
94
|
+
expect(CHARACTER_FX_DURATIONS.map((o) => o.promptHint)).not.toEqual(
|
|
95
|
+
TRANSITION_DURATIONS.map((o) => o.promptHint),
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("carries the timing precision users read off the dropdown", () => {
|
|
100
|
+
// The editor renders straight from here (its own catalogs, not the
|
|
101
|
+
// transition ones), so this is the one place the numbers live.
|
|
102
|
+
expect(CHARACTER_FX_DURATIONS.map((o) => o.label)).toEqual([
|
|
103
|
+
"Auto", "Instant", "Short (~1s)", "Medium (~2s)", "Long (~3s)",
|
|
104
|
+
])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("gives every injecting step a compact term, so compact mode never drops it", () => {
|
|
108
|
+
// `catalog-terms.test.ts` enforces this globally; pinned here too because
|
|
109
|
+
// the platform composes timing from `promptHint` in BOTH hint modes, so a
|
|
110
|
+
// broken `term` would only ever surface on an external compact client.
|
|
111
|
+
for (const [field, options] of DIMENSIONS) {
|
|
112
|
+
for (const o of options.slice(1)) {
|
|
113
|
+
expect(o.term, `${field}/${o.id} needs a compact term`).toBeTruthy()
|
|
114
|
+
}
|
|
115
|
+
expect(options[0]!.term, `${field}/auto injects nothing`).toBe("")
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("derives a clause for every non-auto step, so the composer can't index a hole", () => {
|
|
120
|
+
// The regression this guards: a step present in the catalog but missing
|
|
121
|
+
// from the clause table made `parts.push(undefined)` render as a dangling
|
|
122
|
+
// ", " on a prompt that shipped to the provider. Walks the ARRAYS, not a
|
|
123
|
+
// hardcoded id list, so a step added to a catalog is exercised here too.
|
|
124
|
+
for (const [field, options] of DIMENSIONS) {
|
|
125
|
+
for (const o of options.slice(1)) {
|
|
126
|
+
const composed = composeCharacterFxHintFromConnections("werewolf", [], {
|
|
127
|
+
[field]: o.id,
|
|
128
|
+
} as never)
|
|
129
|
+
expect(composed, `${field}/${o.id} composed a dangling separator`).not.toMatch(/,\s*$/)
|
|
130
|
+
expect(composed, `${field}/${o.id}`).not.toContain("undefined")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("every scale leads with a no-op `auto` that injects nothing", () => {
|
|
136
|
+
for (const [field, options] of DIMENSIONS) {
|
|
137
|
+
expect(options[0]!.id, `${field} must lead with auto`).toBe("auto")
|
|
138
|
+
expect(options[0]!.promptHint, `${field}/auto must inject nothing`).toBe("")
|
|
139
|
+
// Every other step must actually carry a clause.
|
|
140
|
+
for (const o of options.slice(1)) {
|
|
141
|
+
expect(o.promptHint, `${field}/${o.id} needs a promptHint`).not.toBe("")
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("the composed clause is the catalog's own promptHint, verbatim", () => {
|
|
147
|
+
// One source of truth: if the composer ever grows a private copy of these
|
|
148
|
+
// strings, the composed sentence stops containing the advertised hint.
|
|
149
|
+
for (const o of CHARACTER_FX_POSITIONS.slice(1)) {
|
|
150
|
+
const composed = composeCharacterFxHintFromConnections("werewolf", [], { position: o.id as never })
|
|
151
|
+
expect(composed, `position/${o.id}`).toContain(o.promptHint)
|
|
152
|
+
}
|
|
153
|
+
for (const o of CHARACTER_FX_DURATIONS.slice(1)) {
|
|
154
|
+
const composed = composeCharacterFxHintFromConnections("werewolf", [], { duration: o.id as never })
|
|
155
|
+
expect(composed, `duration/${o.id}`).toContain(o.promptHint)
|
|
156
|
+
}
|
|
157
|
+
for (const o of CHARACTER_FX_INTENSITIES.slice(1)) {
|
|
158
|
+
const composed = composeCharacterFxHintFromConnections("werewolf", [], { intensity: o.id as never })
|
|
159
|
+
expect(composed, `intensity/${o.id}`).toContain(o.promptHint)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("the clauses are emitted identically in compact hint mode", () => {
|
|
164
|
+
// Timing is composed from `promptHint` in BOTH modes — only the effect's
|
|
165
|
+
// base fragment swaps to its term. Pinned so a compact-mode refactor cannot
|
|
166
|
+
// quietly start reading `term` for the timing rows.
|
|
167
|
+
for (const [field, options] of DIMENSIONS) {
|
|
168
|
+
for (const o of options.slice(1)) {
|
|
169
|
+
const compact = composeCharacterFxHintFromConnections("werewolf", [], {
|
|
170
|
+
[field]: o.id,
|
|
171
|
+
} as never, "compact")
|
|
172
|
+
expect(compact, `${field}/${o.id} in compact mode`).toContain(o.promptHint)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("`auto` adds nothing to the composed sentence", () => {
|
|
178
|
+
const bare = composeCharacterFxHintFromConnections("werewolf", [])
|
|
179
|
+
const allAuto = composeCharacterFxHintFromConnections("werewolf", [], { position: "auto", duration: "auto", intensity: "auto" })
|
|
180
|
+
expect(allAuto).toBe(bare)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("exposes the three dimensions on the character-fx catalog", () => {
|
|
184
|
+
expect(characterFxCatalog.kind).toBe("single")
|
|
185
|
+
expect(characterFxCatalog.dimensions?.map((d) => d.field)).toEqual([
|
|
186
|
+
"position",
|
|
187
|
+
"duration",
|
|
188
|
+
"intensity",
|
|
189
|
+
])
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("keeps the dimensions through the wire projection", () => {
|
|
193
|
+
const wire = projectPickerCatalog(characterFxCatalog, { detail: "full" })
|
|
194
|
+
|
|
195
|
+
// The 57-option effect list is untouched by the addition.
|
|
196
|
+
expect(wire.options?.length).toBe(characterFxCatalog.options?.length)
|
|
197
|
+
|
|
198
|
+
expect(wire.dimensions).toHaveLength(3)
|
|
199
|
+
for (const [field, options] of DIMENSIONS) {
|
|
200
|
+
const dim = wire.dimensions?.find((d) => d.field === field)
|
|
201
|
+
expect(dim, `${field} must reach the wire`).toBeDefined()
|
|
202
|
+
expect(dim!.options.map((o) => o.id)).toEqual(options.map((o) => o.id))
|
|
203
|
+
expect(dim!.options.map((o) => o.promptHint)).toEqual(options.map((o) => o.promptHint))
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("field filter narrows a single-dim catalog to one secondary dimension", () => {
|
|
208
|
+
const wire = projectPickerCatalog(characterFxCatalog, { field: "duration" })
|
|
209
|
+
expect(wire.options?.length).toBe(characterFxCatalog.options?.length)
|
|
210
|
+
expect(wire.dimensions?.map((d) => d.field)).toEqual(["duration"])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe("projectAllCatalogs — what an id-only client actually reads", () => {
|
|
214
|
+
// `GET /v1/catalogs` is built from this call. Both detail levels must carry
|
|
215
|
+
// the three dimensions with ids and labels; `full` must also carry the
|
|
216
|
+
// clause, and `compact` must carry the term so a thin client can inject
|
|
217
|
+
// without a second fetch.
|
|
218
|
+
for (const detail of ["compact", "full"] as const) {
|
|
219
|
+
it(`detail=${detail}: three dimensions with ids, labels${detail === "full" ? " and hints" : " and terms"}`, () => {
|
|
220
|
+
const wire = projectAllCatalogs({ detail }).find((c) => c.nodeType === "character-fx")!
|
|
221
|
+
expect(wire.detail).toBe(detail)
|
|
222
|
+
expect(wire.kind).toBe("single")
|
|
223
|
+
expect(wire.dimensions?.map((d) => d.field)).toEqual(["position", "duration", "intensity"])
|
|
224
|
+
|
|
225
|
+
for (const [field, options] of DIMENSIONS) {
|
|
226
|
+
const dim = wire.dimensions!.find((d) => d.field === field)!
|
|
227
|
+
expect(dim.label, `${field} label`).toBeTruthy()
|
|
228
|
+
expect(dim.options.map((o) => o.id)).toEqual(options.map((o) => o.id))
|
|
229
|
+
expect(dim.options.map((o) => o.label)).toEqual(options.map((o) => o.label))
|
|
230
|
+
expect(dim.options.map((o) => o.term)).toEqual(options.map((o) => o.term))
|
|
231
|
+
if (detail === "full") {
|
|
232
|
+
expect(dim.options.map((o) => o.promptHint)).toEqual(options.map((o) => o.promptHint))
|
|
233
|
+
} else {
|
|
234
|
+
for (const o of dim.options) expect(o, `${field}/${o.id} compact`).not.toHaveProperty("promptHint")
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
})
|
|
@@ -3,8 +3,24 @@ import { FACTORY_PRESETS, getFactoryPresets, groupFactoryPresets } from "../fact
|
|
|
3
3
|
import { extractPresetData, PRESET_APPLY_CLEAR_KEYS } from "@nodaro/shared"
|
|
4
4
|
import { COMPOSER_PLAN_MAP, COMPOSER_PLAN_FIELDS } from "@nodaro/shared"
|
|
5
5
|
import { STYLE_IDS } from "../index.js"
|
|
6
|
+
import { joinPromptParts } from "../resolve-prompt.js"
|
|
7
|
+
import { nodeSupportsPromptAffixes } from "../node-prompt-fields.js"
|
|
6
8
|
import { IMAGE_GEN_PROVIDERS, MODIFY_IMAGE_PROVIDERS, VIDEO_GEN_PROVIDERS, VIDEO_TO_VIDEO_PROVIDERS, MUSIC_PROVIDERS, SUNO_MODELS, TTS_PROVIDERS, TEXT_TO_AUDIO_PROVIDERS, ALL_CAPTION_STYLES, COMBINE_TRANSITION_IDS, AUDIO_CROSSFADE_CURVE_IDS, aspectRatioOptionsByKind, durationsByMode, IMAGE_PROMPT_MAX, MODEL_CATALOG, NATIVE_NEGATIVE_VIDEO_PROVIDERS } from "@nodaro/shared"
|
|
7
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The text a preset ACTUALLY sends: a preset may ship its doctrine as pre/post
|
|
12
|
+
* text (`promptPrefix` / `promptSuffix`) with the `prompt` field left free for
|
|
13
|
+
* the user — see docs/prompt-pre-post-text.md. Every content guard below reads
|
|
14
|
+
* the ASSEMBLED prompt through this helper (same join rule the run uses) so a
|
|
15
|
+
* prompt→affix move can never silently drop a clause or blow a length cap.
|
|
16
|
+
*/
|
|
17
|
+
const effectivePrompt = (d: Record<string, unknown>) =>
|
|
18
|
+
joinPromptParts([
|
|
19
|
+
d.promptPrefix as string | undefined,
|
|
20
|
+
d.prompt as string | undefined,
|
|
21
|
+
d.promptSuffix as string | undefined,
|
|
22
|
+
])
|
|
23
|
+
|
|
8
24
|
describe("FACTORY_PRESETS", () => {
|
|
9
25
|
it("has presets for generate-image", () => {
|
|
10
26
|
expect(getFactoryPresets("generate-image").length).toBeGreaterThan(0)
|
|
@@ -74,7 +90,7 @@ describe("Seedance Director factory presets", () => {
|
|
|
74
90
|
for (const p of pack) {
|
|
75
91
|
const prompt = p.data.prompt as string
|
|
76
92
|
expect(prompt).not.toMatch(/\(\s*\d+\s*[-–]\s*\d+\s*s\s*\)/i) // "(0-3s)" style
|
|
77
|
-
expect(
|
|
93
|
+
expect(effectivePrompt(p.data).length).toBeLessThanOrEqual(2000)
|
|
78
94
|
expect(prompt).toContain("subtitle-free")
|
|
79
95
|
}
|
|
80
96
|
})
|
|
@@ -157,7 +173,7 @@ describe("generate-image factory preset data validity", () => {
|
|
|
157
173
|
|
|
158
174
|
it("respects prompt (IMAGE_PROMPT_MAX) and negativePrompt (5000) length caps", () => {
|
|
159
175
|
for (const p of presets) {
|
|
160
|
-
const prompt = (p.data
|
|
176
|
+
const prompt = effectivePrompt(p.data)
|
|
161
177
|
const neg = (p.data.negativePrompt as string | undefined) ?? ""
|
|
162
178
|
expect(prompt.length, `${p.id}: prompt exceeds cap`).toBeLessThanOrEqual(IMAGE_PROMPT_MAX)
|
|
163
179
|
expect(neg.length, `${p.id}: negativePrompt exceeds 5000 chars`).toBeLessThanOrEqual(5000)
|
|
@@ -211,7 +227,7 @@ describe("generate-image factory preset data validity", () => {
|
|
|
211
227
|
const boards = presets.filter((p) => p.group === "Reference Sheet")
|
|
212
228
|
expect(boards.length).toBeGreaterThanOrEqual(11)
|
|
213
229
|
for (const b of boards) {
|
|
214
|
-
expect(b.data
|
|
230
|
+
expect(effectivePrompt(b.data), `${b.id}: missing the never-merge-panels clause`).toContain(
|
|
215
231
|
"never merging or omitting a panel",
|
|
216
232
|
)
|
|
217
233
|
}
|
|
@@ -239,7 +255,7 @@ describe("generate-image factory preset data validity", () => {
|
|
|
239
255
|
const handmade = presets.filter((p) => p.group === "Handmade & Stop-Motion")
|
|
240
256
|
expect(handmade.length).toBeGreaterThanOrEqual(6)
|
|
241
257
|
for (const p of handmade) {
|
|
242
|
-
expect(p.data
|
|
258
|
+
expect(effectivePrompt(p.data), `${p.id}: missing the NOT digital CG clause`).toContain("NOT digital CG")
|
|
243
259
|
}
|
|
244
260
|
})
|
|
245
261
|
|
|
@@ -264,7 +280,7 @@ describe("generate-image factory preset data validity", () => {
|
|
|
264
280
|
it("non-style-pinned generate-image prompts are substantive (no thin one-liners)", () => {
|
|
265
281
|
for (const p of presets) {
|
|
266
282
|
if (p.data.style !== undefined) continue // style field carries the look; short prompt is fine
|
|
267
|
-
const prompt = (p.data
|
|
283
|
+
const prompt = effectivePrompt(p.data)
|
|
268
284
|
expect(prompt.trim().length, `${p.id}: prompt too thin (${prompt.length} chars)`).toBeGreaterThanOrEqual(40)
|
|
269
285
|
}
|
|
270
286
|
})
|
|
@@ -296,7 +312,7 @@ describe("modify-image factory preset data validity", () => {
|
|
|
296
312
|
|
|
297
313
|
it("respects the prompt 2000-char cap", () => {
|
|
298
314
|
for (const p of presets) {
|
|
299
|
-
const prompt = (p.data
|
|
315
|
+
const prompt = effectivePrompt(p.data)
|
|
300
316
|
expect(prompt.length, `${p.id}: prompt exceeds 2000 chars`).toBeLessThanOrEqual(2000)
|
|
301
317
|
}
|
|
302
318
|
})
|
|
@@ -349,7 +365,7 @@ describe("generate-video factory preset data validity", () => {
|
|
|
349
365
|
|
|
350
366
|
it("respects prompt/negativePrompt length caps (2500)", () => {
|
|
351
367
|
for (const p of presets) {
|
|
352
|
-
expect((
|
|
368
|
+
expect(effectivePrompt(p.data).length, `${p.id}: prompt too long`).toBeLessThanOrEqual(2500)
|
|
353
369
|
expect(((p.data.negativePrompt as string) ?? "").length, `${p.id}: negativePrompt too long`).toBeLessThanOrEqual(2500)
|
|
354
370
|
}
|
|
355
371
|
})
|
|
@@ -737,7 +753,7 @@ describe("video-to-video factory preset data validity", () => {
|
|
|
737
753
|
|
|
738
754
|
it("respects prompt (5000) and negativePrompt (500) caps", () => {
|
|
739
755
|
for (const p of presets) {
|
|
740
|
-
expect((
|
|
756
|
+
expect(effectivePrompt(p.data).length, `${p.id}: prompt > 5000`).toBeLessThanOrEqual(5000)
|
|
741
757
|
expect(((p.data.negativePrompt as string) ?? "").length, `${p.id}: negativePrompt > 500`).toBeLessThanOrEqual(500)
|
|
742
758
|
}
|
|
743
759
|
})
|
|
@@ -1006,13 +1022,19 @@ describe("presets don't bake config-field values (framing) into the prompt", ()
|
|
|
1006
1022
|
const ratioRe = new RegExp(`(^|[^0-9.])(${RATIO_TOKENS.map((t) => t.replace(/\./g, "\\.")).join("|")})([^0-9]|$)`)
|
|
1007
1023
|
|
|
1008
1024
|
it("no factory preset prompt names an aspect ratio", () => {
|
|
1025
|
+
// Checks all three prompt-bearing fields: a preset may ship its doctrine as
|
|
1026
|
+
// pre/post text, and a ratio smuggled into an affix reaches the model just
|
|
1027
|
+
// the same. The message names the offending field.
|
|
1009
1028
|
const offenders: string[] = []
|
|
1010
1029
|
for (const [, presets] of Object.entries(FACTORY_PRESETS)) {
|
|
1011
1030
|
for (const p of presets) {
|
|
1012
|
-
const
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1031
|
+
const d = p.data as Record<string, unknown>
|
|
1032
|
+
for (const field of ["promptPrefix", "prompt", "promptSuffix"] as const) {
|
|
1033
|
+
const text = d[field]
|
|
1034
|
+
if (typeof text !== "string") continue
|
|
1035
|
+
const m = text.match(ratioRe)
|
|
1036
|
+
if (m) offenders.push(`${p.id}.${field} → "${m[2]}"`)
|
|
1037
|
+
}
|
|
1016
1038
|
}
|
|
1017
1039
|
}
|
|
1018
1040
|
expect(
|
|
@@ -1022,6 +1044,34 @@ describe("presets don't bake config-field values (framing) into the prompt", ()
|
|
|
1022
1044
|
})
|
|
1023
1045
|
})
|
|
1024
1046
|
|
|
1047
|
+
describe("factory preset pre/post text (promptPrefix / promptSuffix)", () => {
|
|
1048
|
+
// A preset may ship its doctrine as pre/post text so the prompt field stays
|
|
1049
|
+
// free for the user (docs/prompt-pre-post-text.md). Two invariants: the node
|
|
1050
|
+
// type must actually support affixes — otherwise the text is dead data the
|
|
1051
|
+
// run never wraps — and a shipped affix must carry real content, since a
|
|
1052
|
+
// blank one is dropped by the join and silently loses the doctrine.
|
|
1053
|
+
it("every affix-carrying factory preset is affix-capable and ships no empty affix", () => {
|
|
1054
|
+
for (const [nodeType, presets] of Object.entries(FACTORY_PRESETS)) {
|
|
1055
|
+
for (const p of presets) {
|
|
1056
|
+
const d = p.data as Record<string, unknown>
|
|
1057
|
+
const affixes = (["promptPrefix", "promptSuffix"] as const).filter(
|
|
1058
|
+
(k) => d[k] !== undefined,
|
|
1059
|
+
)
|
|
1060
|
+
if (affixes.length === 0) continue
|
|
1061
|
+
expect(
|
|
1062
|
+
nodeSupportsPromptAffixes(nodeType),
|
|
1063
|
+
`${p.id}: ships ${affixes.join("/")} but "${nodeType}" does not support pre/post text`,
|
|
1064
|
+
).toBe(true)
|
|
1065
|
+
for (const k of affixes) {
|
|
1066
|
+
const v = d[k]
|
|
1067
|
+
expect(typeof v, `${p.id}: ${k} must be a string`).toBe("string")
|
|
1068
|
+
expect((v as string).trim().length, `${p.id}: ${k} is blank`).toBeGreaterThan(0)
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
})
|
|
1073
|
+
})
|
|
1074
|
+
|
|
1025
1075
|
describe("factory-presets split integrity", () => {
|
|
1026
1076
|
// The exact node-type keys, in the exact insertion order the single-file
|
|
1027
1077
|
// catalog declared them. The split's index.ts must reproduce this order.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { NODE_PROMPT_FIELDS, nodeSupportsPromptAffixes, PROMPT_AFFIX_NODE_TYPES, getPromptFields, promptAffixCoreField, promptFieldCarriesAffixes, PROMPT_AFFIX_CORE_FIELD_OVERRIDES } from "../node-prompt-fields.js"
|
|
3
|
+
import { NODE_PROMPT_CANDIDATE_FIELDS } from "../resolve-prompt.js"
|
|
4
|
+
|
|
5
|
+
describe("nodeSupportsPromptAffixes", () => {
|
|
6
|
+
it("is true for every registered prompt node except explicit opt-outs", () => {
|
|
7
|
+
for (const [type, spec] of Object.entries(NODE_PROMPT_FIELDS)) {
|
|
8
|
+
expect(nodeSupportsPromptAffixes(type)).toBe(spec.affixes !== false)
|
|
9
|
+
}
|
|
10
|
+
})
|
|
11
|
+
it("text-prompt (plain Text input) opts out", () => expect(nodeSupportsPromptAffixes("text-prompt")).toBe(false))
|
|
12
|
+
it("generate-image / llm-chat / video-analysis support affixes", () => {
|
|
13
|
+
for (const t of ["generate-image", "llm-chat", "video-analysis"]) expect(nodeSupportsPromptAffixes(t)).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
it("unknown / undefined → false", () => {
|
|
16
|
+
expect(nodeSupportsPromptAffixes("not-a-node")).toBe(false)
|
|
17
|
+
expect(nodeSupportsPromptAffixes(undefined)).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
it("PROMPT_AFFIX_NODE_TYPES is exactly the registry minus opt-outs (37 types)", () => {
|
|
20
|
+
const expected = Object.entries(NODE_PROMPT_FIELDS).filter(([, s]) => s.affixes !== false).map(([t]) => t)
|
|
21
|
+
expect([...PROMPT_AFFIX_NODE_TYPES].sort()).toEqual(expected.sort())
|
|
22
|
+
expect(PROMPT_AFFIX_NODE_TYPES.size).toBe(37)
|
|
23
|
+
})
|
|
24
|
+
it("getPromptFields still resolves", () => expect(getPromptFields("generate-image")?.prompt).toBe("prompt"))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe("promptAffixCoreField — the data key the RUN wraps with pre/post text", () => {
|
|
28
|
+
it("most nodes: the editor's primary prompt field", () => {
|
|
29
|
+
expect(promptAffixCoreField("generate-image")).toBe("prompt")
|
|
30
|
+
expect(promptAffixCoreField("llm-chat")).toBe("userInput")
|
|
31
|
+
expect(promptAffixCoreField("text-to-speech")).toBe("directText")
|
|
32
|
+
})
|
|
33
|
+
it("generate-script: the run wraps the topic `prompt`, NOT the editor's styleGuide (spec §7)", () => {
|
|
34
|
+
expect(getPromptFields("generate-script")?.prompt).toBe("styleGuide")
|
|
35
|
+
expect(promptAffixCoreField("generate-script")).toBe("prompt")
|
|
36
|
+
})
|
|
37
|
+
it("nodes with no affixes have no core field", () => {
|
|
38
|
+
expect(promptAffixCoreField("text-prompt")).toBeUndefined()
|
|
39
|
+
expect(promptAffixCoreField("not-a-node")).toBeUndefined()
|
|
40
|
+
expect(promptAffixCoreField(undefined)).toBeUndefined()
|
|
41
|
+
})
|
|
42
|
+
it("every affix-capable type resolves to a core field", () => {
|
|
43
|
+
for (const t of PROMPT_AFFIX_NODE_TYPES) expect(promptAffixCoreField(t)).toBeTruthy()
|
|
44
|
+
})
|
|
45
|
+
it("every override key is an affix-capable node type", () => {
|
|
46
|
+
for (const t of Object.keys(PROMPT_AFFIX_CORE_FIELD_OVERRIDES)) {
|
|
47
|
+
expect(PROMPT_AFFIX_NODE_TYPES.has(t)).toBe(true)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("promptFieldCarriesAffixes — does previewing this key show the run's affixes?", () => {
|
|
53
|
+
it("the core field carries them; a sibling field does not", () => {
|
|
54
|
+
expect(promptFieldCarriesAffixes("llm-chat", "userInput")).toBe(true)
|
|
55
|
+
expect(promptFieldCarriesAffixes("llm-chat", "systemPrompt")).toBe(false)
|
|
56
|
+
expect(promptFieldCarriesAffixes("generate-script", "prompt")).toBe(true)
|
|
57
|
+
expect(promptFieldCarriesAffixes("generate-script", "styleGuide")).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
it("a run-time FALLBACK candidate carries them too (i2v's legacy motionPrompt)", () => {
|
|
60
|
+
// computeNodePrompt picks the first present of ["prompt", "motionPrompt"]
|
|
61
|
+
// and wraps THAT with the affixes — so previewing motionPrompt must wrap.
|
|
62
|
+
expect(promptFieldCarriesAffixes("image-to-video", "motionPrompt")).toBe(true)
|
|
63
|
+
expect(promptFieldCarriesAffixes("generate-video", "motionPrompt")).toBe(true)
|
|
64
|
+
expect(promptFieldCarriesAffixes("text-to-audio", "text")).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
it("an affix-less node never carries them, whatever the field", () => {
|
|
67
|
+
expect(promptFieldCarriesAffixes("text-prompt", "text")).toBe(false)
|
|
68
|
+
expect(promptFieldCarriesAffixes("not-a-node", "prompt")).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
it("omitting the field falls back to the node's primary prompt field", () => {
|
|
71
|
+
expect(promptFieldCarriesAffixes("generate-image", undefined)).toBe(true)
|
|
72
|
+
// generate-script's primary (styleGuide) is NOT the core → no wrap.
|
|
73
|
+
expect(promptFieldCarriesAffixes("generate-script", undefined)).toBe(false)
|
|
74
|
+
expect(promptFieldCarriesAffixes("text-prompt", undefined)).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
it("no override type also declares run-time candidates (the two rules never collide)", () => {
|
|
77
|
+
for (const t of Object.keys(PROMPT_AFFIX_CORE_FIELD_OVERRIDES)) {
|
|
78
|
+
expect(NODE_PROMPT_CANDIDATE_FIELDS[t]).toBeUndefined()
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -134,7 +134,10 @@ const SAMPLE_DATA_BY_TYPE: Record<string, Record<string, unknown>> = {
|
|
|
134
134
|
"held-prop": { heldProp: firstId(HELD_PROPS) },
|
|
135
135
|
"exposure-settings": { aperture: firstId(EXPOSURE_SETTINGS) },
|
|
136
136
|
"render-quality": { renderQuality: firstId(RENDER_QUALITIES) },
|
|
137
|
-
|
|
137
|
+
// Not firstId: the head of the catalog is the neutral "none" entry, whose
|
|
138
|
+
// promptHint is deliberately empty (same reason transition/character-fx below
|
|
139
|
+
// name a real id instead of their "auto" head).
|
|
140
|
+
"composition-effects": { compositionEffect: "bursting-through-frame" },
|
|
138
141
|
"post-process-effects": { postProcess: firstId(POST_PROCESS_EFFECTS) },
|
|
139
142
|
"action-fx": { actionFx: firstId(ACTION_FX) },
|
|
140
143
|
// TRANSITIONS[0] is "auto" with an intentionally empty promptHint — use a
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
promptPartSeparator,
|
|
4
|
+
joinPromptParts,
|
|
5
|
+
applyPromptAffixes,
|
|
6
|
+
resolvePrompt,
|
|
7
|
+
computeNodePrompt,
|
|
8
|
+
computeLlmChatFields,
|
|
9
|
+
} from "../resolve-prompt.js"
|
|
10
|
+
|
|
11
|
+
const M = new Map<string, string>()
|
|
12
|
+
const R = new Map([["Character", "Mira"], ["Hero", "a knight"]])
|
|
13
|
+
|
|
14
|
+
describe("promptPartSeparator", () => {
|
|
15
|
+
it("inserts a space between two words", () => expect(promptPartSeparator("a", "b")).toBe(" "))
|
|
16
|
+
it("no space when left ends with whitespace", () => expect(promptPartSeparator("a ", "b")).toBe(""))
|
|
17
|
+
it("no space when left ends with a newline", () => expect(promptPartSeparator("a\n\n", "b")).toBe(""))
|
|
18
|
+
it("no space when right starts with whitespace", () => expect(promptPartSeparator("a", " b")).toBe(""))
|
|
19
|
+
it.each([",", ".", ";", ":", "!", "?", ")"])("no space when right starts with %s", (p) =>
|
|
20
|
+
expect(promptPartSeparator("a", `${p} b`)).toBe(""))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe("joinPromptParts (spec §4.2)", () => {
|
|
24
|
+
it("prefix + core + comma-suffix", () =>
|
|
25
|
+
expect(joinPromptParts(["Cinematic 35mm still of", "a woman in Tokyo", ", golden hour"]))
|
|
26
|
+
.toBe("Cinematic 35mm still of a woman in Tokyo, golden hour"))
|
|
27
|
+
it("keeps an author's trailing newlines", () =>
|
|
28
|
+
expect(joinPromptParts(["RULES:\n- no text\n\n", "a red shoe", undefined])).toBe("RULES:\n- no text\n\na red shoe"))
|
|
29
|
+
it("core + sentence suffix", () =>
|
|
30
|
+
expect(joinPromptParts([undefined, "a red shoe", "Avoid clutter."])).toBe("a red shoe Avoid clutter."))
|
|
31
|
+
it("drops blank parts", () => expect(joinPromptParts([" ", "x", ""])).toBe("x"))
|
|
32
|
+
it("all blank → empty string", () => expect(joinPromptParts(["", undefined, " "])).toBe(""))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe("applyPromptAffixes", () => {
|
|
36
|
+
it("no affixes → returns the SAME core reference", () => {
|
|
37
|
+
const core = "a red shoe"
|
|
38
|
+
expect(applyPromptAffixes(core, undefined, M)).toBe(core)
|
|
39
|
+
expect(applyPromptAffixes(core, {}, M)).toBe(core)
|
|
40
|
+
expect(applyPromptAffixes(undefined, {}, M)).toBeUndefined()
|
|
41
|
+
})
|
|
42
|
+
it("whitespace-only affixes are a no-op", () => {
|
|
43
|
+
const core = "x"
|
|
44
|
+
expect(applyPromptAffixes(core, { prefix: " ", suffix: "" }, M)).toBe(core)
|
|
45
|
+
})
|
|
46
|
+
it("wraps with the join rule", () =>
|
|
47
|
+
expect(applyPromptAffixes("a woman in Tokyo", { prefix: "Cinematic 35mm still of", suffix: ", golden hour" }, M))
|
|
48
|
+
.toBe("Cinematic 35mm still of a woman in Tokyo, golden hour"))
|
|
49
|
+
it("resolves {Label} refs inside the affixes", () =>
|
|
50
|
+
expect(applyPromptAffixes("", { prefix: "Portrait of {Character}" }, R)).toBe("Portrait of Mira"))
|
|
51
|
+
it("empty core + affixes → the affixes alone", () =>
|
|
52
|
+
expect(applyPromptAffixes(undefined, { prefix: "PRE", suffix: "POST" }, M)).toBe("PRE POST"))
|
|
53
|
+
it("does not touch the core (no ref resolution, no trim)", () =>
|
|
54
|
+
expect(applyPromptAffixes(" {Hero} ", { suffix: "S" }, R)).toBe(" {Hero} S"))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("resolvePrompt × affixes", () => {
|
|
58
|
+
const A = { prefix: "PRE", suffix: "POST" }
|
|
59
|
+
it("wraps the override", () =>
|
|
60
|
+
expect(resolvePrompt({ override: "o", typed: ["t"], wired: "w", refMap: M, affixes: A })).toBe("PRE o POST"))
|
|
61
|
+
it("wraps the typed winner", () =>
|
|
62
|
+
expect(resolvePrompt({ typed: ["t"], wired: "w", refMap: M, affixes: A })).toBe("PRE t POST"))
|
|
63
|
+
it("wraps the wired fallback", () =>
|
|
64
|
+
expect(resolvePrompt({ typed: [""], wired: "w", refMap: M, affixes: A })).toBe("PRE w POST"))
|
|
65
|
+
it("appendWired: wraps the COMBINED typed. wired core", () =>
|
|
66
|
+
expect(resolvePrompt({ typed: ["t"], wired: "w", refMap: M, appendWired: true, affixes: A })).toBe("PRE t. w POST"))
|
|
67
|
+
it("nothing + affixes → affixes alone", () =>
|
|
68
|
+
expect(resolvePrompt({ typed: [], refMap: M, affixes: A })).toBe("PRE POST"))
|
|
69
|
+
it("without affixes is byte-identical to before", () =>
|
|
70
|
+
expect(resolvePrompt({ typed: ["x {Hero} y"], refMap: R })).toBe("x a knight y"))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("computeNodePrompt / computeLlmChatFields read affixes off data", () => {
|
|
74
|
+
it("generate-image", () =>
|
|
75
|
+
expect(computeNodePrompt("generate-image", { prompt: "typed", promptPrefix: "PRE", promptSuffix: "POST" }, { wired: "wire", refMap: M }))
|
|
76
|
+
.toBe("PRE typed POST"))
|
|
77
|
+
it("text-to-speech direct", () =>
|
|
78
|
+
expect(computeNodePrompt("text-to-speech", { textSource: "direct", directText: "d", promptSuffix: "POST" }, { refMap: M }))
|
|
79
|
+
.toBe("d POST"))
|
|
80
|
+
it("llm-chat: userInput wrapped, systemPrompt never", () =>
|
|
81
|
+
expect(computeLlmChatFields({ userInput: "u", systemPrompt: "s", promptPrefix: "PRE" }, { refMap: M }))
|
|
82
|
+
.toEqual({ userInput: "PRE u", systemPrompt: "s" }))
|
|
83
|
+
})
|