@nodaro/prompts 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +426 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +830 -51
- package/dist/index.d.ts +830 -51
- package/dist/index.js +410 -37
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-image-input.test.ts +93 -3
- package/src/__tests__/assemble-video-input.test.ts +301 -0
- package/src/__tests__/character-fx-timing-catalogs.test.ts +240 -0
- package/src/__tests__/direction-hint-token-safety.test.ts +113 -0
- package/src/__tests__/direction-registry.test.ts +393 -0
- package/src/__tests__/image-convergence-image.test.ts +370 -0
- package/src/__tests__/read-node-direction.test.ts +154 -0
- package/src/__tests__/transition-timing-catalogs.test.ts +3 -1
- package/src/__tests__/video-reference-ref-id-tokens.test.ts +301 -0
- package/src/assemble-image-input.ts +45 -46
- package/src/assemble-video-input.ts +89 -0
- package/src/character-fx.ts +102 -19
- package/src/direction-registry.ts +354 -0
- package/src/index.ts +8 -2
- package/src/picker-catalogs.ts +18 -1
- package/src/prompt-builder.ts +188 -1
- package/src/prompt-hint-join.ts +30 -0
- package/src/provider-prompt-doctrine.ts +2 -2
- package/src/read-node-direction.ts +174 -0
- package/src/ref-binding.ts +45 -0
- package/src/ref-id-tokens.ts +112 -0
- 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.11.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.16.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"tsup": "^8.5.0",
|
|
@@ -3,6 +3,10 @@ import { assembleImageInput } from "../assemble-image-input.js"
|
|
|
3
3
|
import { buildImagePrompt } from "../prompt-builder.js"
|
|
4
4
|
import { getFramingPromptHint } from "../framing.js"
|
|
5
5
|
import { getLightingPromptHint } from "../lighting.js"
|
|
6
|
+
import { getLensPromptHint } from "../lens.js"
|
|
7
|
+
import { getCameraFormatPromptHint } from "../camera-format.js"
|
|
8
|
+
import { getStylePromptHint } from "../style.js"
|
|
9
|
+
import { buildMoodHints } from "../mood.js"
|
|
6
10
|
import type { ConnectedReference } from "@nodaro/shared"
|
|
7
11
|
|
|
8
12
|
/**
|
|
@@ -11,9 +15,9 @@ import type { ConnectedReference } from "@nodaro/shared"
|
|
|
11
15
|
* `generate-image` assembly through it. These tests pin BOTH layers:
|
|
12
16
|
* (a) the id-based composition (direction / structured) — ported from
|
|
13
17
|
* Studio's `assembly.test.ts` as the oracle, and
|
|
14
|
-
* (b) the BY-CONSTRUCTION PARITY the caller refactor relies on:
|
|
15
|
-
* direction/structured, the wrapper === the old inline
|
|
16
|
-
* call + empty-check, byte-for-byte.
|
|
18
|
+
* (b) the BY-CONSTRUCTION PARITY the caller refactor relies on: for a node
|
|
19
|
+
* that carries no direction/structured, the wrapper === the old inline
|
|
20
|
+
* `buildImagePrompt` call + empty-check, byte-for-byte.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
// flux-2-max supports reference images (used to assert refs survive the gate).
|
|
@@ -93,6 +97,92 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
|
|
|
93
97
|
})
|
|
94
98
|
expect(result.prompt).toBe("a portrait. Subject: 30 years old, woman, calm expression.")
|
|
95
99
|
})
|
|
100
|
+
|
|
101
|
+
// ── The direction registry (the fold moved into `direction-registry.ts`) ──
|
|
102
|
+
|
|
103
|
+
it("returns the prompt VERBATIM and UNTRIMMED for a direction that renders nothing", () => {
|
|
104
|
+
// The no-op branch is what the platform-caller parity contract rests on: an
|
|
105
|
+
// empty (or all-empty-valued) `direction` must not trip the join, or the
|
|
106
|
+
// prompt would silently get trimmed.
|
|
107
|
+
for (const direction of [{}, { style: "" }, { mood: [] }, { style: "__no_such_style__" }]) {
|
|
108
|
+
const result = assembleImageInput({
|
|
109
|
+
userPrompt: " a knight \n",
|
|
110
|
+
provider: REF_PROVIDER,
|
|
111
|
+
direction,
|
|
112
|
+
})
|
|
113
|
+
expect(result.prompt).toBe(" a knight \n")
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("folds a registry key that predates no legacy field (style) end to end", () => {
|
|
118
|
+
const result = assembleImageInput({
|
|
119
|
+
userPrompt: "a knight",
|
|
120
|
+
provider: REF_PROVIDER,
|
|
121
|
+
direction: { style: "anime" },
|
|
122
|
+
})
|
|
123
|
+
expect(result.prompt).toBe(`a knight. ${getStylePromptHint("anime")}`)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("blends a multi-pick dimension into ONE clause", () => {
|
|
127
|
+
const blended = buildMoodHints({ mood: ["happy", "joyful"] }, "full")
|
|
128
|
+
expect(blended).toHaveLength(1)
|
|
129
|
+
const result = assembleImageInput({
|
|
130
|
+
userPrompt: "a knight",
|
|
131
|
+
provider: REF_PROVIDER,
|
|
132
|
+
direction: { mood: ["happy", "joyful"] },
|
|
133
|
+
})
|
|
134
|
+
expect(result.prompt).toBe(`a knight. ${blended[0]}`)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("folds in TABLE order, not the caller's object-literal order", () => {
|
|
138
|
+
const result = assembleImageInput({
|
|
139
|
+
userPrompt: "a knight",
|
|
140
|
+
provider: REF_PROVIDER,
|
|
141
|
+
direction: { style: "anime", shotSize: "wide-shot" },
|
|
142
|
+
})
|
|
143
|
+
expect(result.prompt).toBe(
|
|
144
|
+
`a knight. ${getFramingPromptHint("wide-shot")}. ${getStylePromptHint("anime")}`,
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("keeps the five pre-registry keys byte-identical to the old inlined fold", () => {
|
|
149
|
+
const direction = {
|
|
150
|
+
framingId: "wide-shot",
|
|
151
|
+
framingAngleId: "low-angle",
|
|
152
|
+
lightingId: "golden-hour",
|
|
153
|
+
lensId: "wide-24mm",
|
|
154
|
+
cameraFormatId: "16mm-film",
|
|
155
|
+
}
|
|
156
|
+
const result = assembleImageInput({
|
|
157
|
+
userPrompt: "a knight",
|
|
158
|
+
provider: REF_PROVIDER,
|
|
159
|
+
direction,
|
|
160
|
+
})
|
|
161
|
+
// The exact string the pre-registry `composePromptText` produced: the same
|
|
162
|
+
// five clauses, in the same order, joined with the same ". ".
|
|
163
|
+
expect(result.prompt).toBe(
|
|
164
|
+
[
|
|
165
|
+
"a knight",
|
|
166
|
+
getFramingPromptHint("wide-shot"),
|
|
167
|
+
getFramingPromptHint("low-angle"),
|
|
168
|
+
getLightingPromptHint("golden-hour"),
|
|
169
|
+
getLensPromptHint("wide-24mm"),
|
|
170
|
+
getCameraFormatPromptHint("16mm-film"),
|
|
171
|
+
].join(". "),
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("keeps the structured fragment LAST, after every direction clause", () => {
|
|
176
|
+
const result = assembleImageInput({
|
|
177
|
+
userPrompt: "a portrait",
|
|
178
|
+
provider: REF_PROVIDER,
|
|
179
|
+
direction: { style: "anime" },
|
|
180
|
+
structured: { person: { age: 30, gender: "woman", expression: "calm" } },
|
|
181
|
+
})
|
|
182
|
+
expect(result.prompt).toBe(
|
|
183
|
+
`a portrait. ${getStylePromptHint("anime")}. Subject: 30 years old, woman, calm expression.`,
|
|
184
|
+
)
|
|
185
|
+
})
|
|
96
186
|
})
|
|
97
187
|
|
|
98
188
|
describe("assembleImageInput — empty-prompt throw (opt-in)", () => {
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { composeVideoPromptText } from "../assemble-video-input.js"
|
|
3
|
+
import { directionFieldsForSurface } from "../direction-registry.js"
|
|
4
|
+
import { getStylePromptHint, getStyleTerm } from "../style.js"
|
|
5
|
+
import { getTransitionPromptHint, getTransitionTerm } from "../transitions.js"
|
|
6
|
+
import { getCameraMotionPromptHint, getCameraMotionTerm } from "../camera-motions.js"
|
|
7
|
+
import { getFramingPromptHint } from "../framing.js"
|
|
8
|
+
import { getLightingPromptHint } from "../lighting.js"
|
|
9
|
+
import { buildMoodHints } from "../mood.js"
|
|
10
|
+
import { buildAestheticHints } from "../aesthetic.js"
|
|
11
|
+
import { buildAtmosphereHints } from "../atmosphere.js"
|
|
12
|
+
import { buildPhotographerHints } from "../photographer.js"
|
|
13
|
+
import { renderStructuredFields } from "../prompt-builder-structured-fields.js"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `composeVideoPromptText` is the video route's ONLY prompt-composition step,
|
|
17
|
+
* so two contracts matter here above everything else:
|
|
18
|
+
*
|
|
19
|
+
* 1. THE NO-OP CONTRACT — with no direction the caller's prompt comes back
|
|
20
|
+
* verbatim and untrimmed, `undefined` included. This is the local
|
|
21
|
+
* restatement of the route-level byte-parity oracle ("backward-compatible:
|
|
22
|
+
* no connectedReferences → prompt + flat refs pass through unchanged" in
|
|
23
|
+
* `backend/src/routes/__tests__/generate-video.test.ts`), and it is what
|
|
24
|
+
* makes this whole leg land dark.
|
|
25
|
+
* 2. THE VERBOSITY POLICY — look dimensions render their full clause, motion
|
|
26
|
+
* dimensions their compact professional term. That split moved from the
|
|
27
|
+
* client to the platform, so it is pinned in both directions.
|
|
28
|
+
*
|
|
29
|
+
* Real catalog ids throughout: every `get*PromptHint` returns `""` on a miss,
|
|
30
|
+
* so a made-up id would make most assertions vacuously pass.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// ── Real ids, one per dimension used below ──────────────────────────────────
|
|
34
|
+
const STYLE = "cinematic" // look
|
|
35
|
+
const TRANSITION = "cross-dissolve" // motion
|
|
36
|
+
const CAMERA_MOTION = "handheld" // motion
|
|
37
|
+
const SHOT_SIZE = "wide-shot" // look, framing catalog
|
|
38
|
+
const TIME_OF_DAY = "dawn" // look, lighting catalog (time-of-day category)
|
|
39
|
+
const LIGHTING_STYLE = "three-point" // look, lighting catalog (style category)
|
|
40
|
+
const PHOTOGRAPHER = "tim-walker" // IMAGE-ONLY dimension
|
|
41
|
+
const NO_SUCH_ID = "__no_such_id__"
|
|
42
|
+
|
|
43
|
+
describe("composeVideoPromptText — the no-op contract", () => {
|
|
44
|
+
it("returns a prompt verbatim when no direction is passed", () => {
|
|
45
|
+
expect(composeVideoPromptText("a knight rides at dusk", undefined)).toBe(
|
|
46
|
+
"a knight rides at dusk",
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("returns a whitespace-only prompt verbatim and UNTRIMMED", () => {
|
|
51
|
+
expect(composeVideoPromptText(" \n", undefined)).toBe(" \n")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("preserves `undefined` (the video prompt is optional)", () => {
|
|
55
|
+
expect(composeVideoPromptText(undefined, undefined)).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("treats an empty direction object as no direction", () => {
|
|
59
|
+
expect(composeVideoPromptText("a knight", {})).toBe("a knight")
|
|
60
|
+
expect(composeVideoPromptText(undefined, {})).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe("composeVideoPromptText — the verbosity policy", () => {
|
|
65
|
+
it("renders a LOOK dimension as its full clause", () => {
|
|
66
|
+
expect(composeVideoPromptText("a knight", { style: STYLE })).toBe(
|
|
67
|
+
`a knight. ${getStylePromptHint(STYLE)}`,
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("renders a MOTION dimension as its compact term, not its full hint", () => {
|
|
72
|
+
const out = composeVideoPromptText("a knight", { transition: TRANSITION })
|
|
73
|
+
expect(out).toBe(`a knight. ${getTransitionTerm(TRANSITION)}`)
|
|
74
|
+
expect(out).not.toContain(getTransitionPromptHint(TRANSITION))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("applies both halves of the split policy in ONE fold", () => {
|
|
78
|
+
const out = composeVideoPromptText("a knight", {
|
|
79
|
+
style: STYLE,
|
|
80
|
+
transition: TRANSITION,
|
|
81
|
+
})
|
|
82
|
+
expect(out).toContain(getStylePromptHint(STYLE))
|
|
83
|
+
expect(out).toContain(getTransitionTerm(TRANSITION))
|
|
84
|
+
expect(out).not.toContain(getTransitionPromptHint(TRANSITION))
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("honors a whole-fold `hintMode` override in both directions", () => {
|
|
88
|
+
// "full" promotes the motion family to its full clause…
|
|
89
|
+
expect(
|
|
90
|
+
composeVideoPromptText("a knight", { transition: TRANSITION }, undefined, {
|
|
91
|
+
hintMode: "full",
|
|
92
|
+
}),
|
|
93
|
+
).toBe(`a knight. ${getTransitionPromptHint(TRANSITION)}`)
|
|
94
|
+
// …and "compact" demotes the look family to its term.
|
|
95
|
+
expect(
|
|
96
|
+
composeVideoPromptText("a knight", { style: STYLE }, undefined, {
|
|
97
|
+
hintMode: "compact",
|
|
98
|
+
}),
|
|
99
|
+
).toBe(`a knight. ${getStyleTerm(STYLE)}`)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe("composeVideoPromptText — ordering", () => {
|
|
104
|
+
it("puts camera motion first (the order Studio and the orchestrator both emit)", () => {
|
|
105
|
+
const out = composeVideoPromptText("a knight", {
|
|
106
|
+
style: STYLE,
|
|
107
|
+
cameraMotion: CAMERA_MOTION,
|
|
108
|
+
})!
|
|
109
|
+
expect(out.indexOf(getCameraMotionTerm(CAMERA_MOTION))).toBeLessThan(
|
|
110
|
+
out.indexOf(getStylePromptHint(STYLE)),
|
|
111
|
+
)
|
|
112
|
+
// Compact motion again — the camera-motion row is `family: "motion"`.
|
|
113
|
+
expect(out).not.toContain(getCameraMotionPromptHint(CAMERA_MOTION))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("folds in TABLE order, not the caller's object order", () => {
|
|
117
|
+
// `shotSize` (row 2) precedes `style` (row 22) however the object is written.
|
|
118
|
+
const out = composeVideoPromptText("a knight", {
|
|
119
|
+
style: STYLE,
|
|
120
|
+
shotSize: SHOT_SIZE,
|
|
121
|
+
})!
|
|
122
|
+
expect(out.indexOf(getFramingPromptHint(SHOT_SIZE))).toBeLessThan(
|
|
123
|
+
out.indexOf(getStylePromptHint(STYLE)),
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("appends the structured fragment AFTER every direction hint", () => {
|
|
128
|
+
const structured = { mood: "wistful" }
|
|
129
|
+
const fragment = renderStructuredFields(structured)
|
|
130
|
+
expect(fragment.length).toBeGreaterThan(0)
|
|
131
|
+
const out = composeVideoPromptText("a knight", { style: STYLE }, structured)!
|
|
132
|
+
expect(out).toBe(`a knight. ${getStylePromptHint(STYLE)}. ${fragment}`)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe("composeVideoPromptText — multi-pick doctrine", () => {
|
|
137
|
+
it("BLENDS two moods into ONE clause (not a per-id loop)", () => {
|
|
138
|
+
const blended = buildMoodHints({ mood: ["happy", "serene"] }, "full")
|
|
139
|
+
expect(blended).toHaveLength(1)
|
|
140
|
+
expect(composeVideoPromptText("a knight", { mood: ["happy", "serene"] })).toBe(
|
|
141
|
+
`a knight. ${blended[0]}`,
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("BLENDS two aesthetics into ONE clause", () => {
|
|
146
|
+
const blended = buildAestheticHints(["y2k", "cottagecore"], "full")
|
|
147
|
+
expect(blended.length).toBeGreaterThan(0)
|
|
148
|
+
expect(
|
|
149
|
+
composeVideoPromptText("a knight", { aesthetic: ["y2k", "cottagecore"] }),
|
|
150
|
+
).toBe(`a knight. ${blended}`)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("slices an over-cap array to the dimension's maxPicks (atmosphere = 2)", () => {
|
|
154
|
+
const out = composeVideoPromptText("a knight", {
|
|
155
|
+
atmosphere: ["clear", "cloudy", "overcast"],
|
|
156
|
+
})!
|
|
157
|
+
const kept = buildAtmosphereHints(["clear", "cloudy"], "full")
|
|
158
|
+
expect(kept).toHaveLength(2)
|
|
159
|
+
expect(out).toBe(`a knight. ${kept.join(". ")}`)
|
|
160
|
+
expect(out).not.toContain(buildAtmosphereHints("overcast", "full")[0])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("accepts an ARRAY on a single-pick key and keeps the first id", () => {
|
|
164
|
+
// The legacy `V2LookPicker` shape: a single-pick dimension that stored an
|
|
165
|
+
// array. Must degrade to one hint, never throw and never drop the key.
|
|
166
|
+
const out = composeVideoPromptText("a knight", { style: [STYLE, "anime"] })
|
|
167
|
+
expect(out).toBe(`a knight. ${getStylePromptHint(STYLE)}`)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe("composeVideoPromptText — tolerance", () => {
|
|
172
|
+
it("skips an unknown id and leaves the prompt verbatim (no dangling '. ')", () => {
|
|
173
|
+
expect(composeVideoPromptText("a knight", { style: NO_SUCH_ID })).toBe("a knight")
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("skips an IMAGE-ONLY dimension sent to a video run", () => {
|
|
177
|
+
// `photographer` is accepted on the wire (surface is a render concern, not
|
|
178
|
+
// a wire concern) and simply contributes nothing here.
|
|
179
|
+
expect(buildPhotographerHints(PHOTOGRAPHER, "full").length).toBeGreaterThan(0)
|
|
180
|
+
expect(composeVideoPromptText("a knight", { photographer: PHOTOGRAPHER })).toBe(
|
|
181
|
+
"a knight",
|
|
182
|
+
)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("skips an unknown wire key entirely", () => {
|
|
186
|
+
expect(
|
|
187
|
+
composeVideoPromptText("a knight", { __not_a_dimension__: "x" } as never),
|
|
188
|
+
).toBe("a knight")
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe("composeVideoPromptText — an empty or absent body", () => {
|
|
193
|
+
it("returns the hints alone for an empty prompt (never a leading '. ')", () => {
|
|
194
|
+
expect(composeVideoPromptText("", { style: STYLE })).toBe(getStylePromptHint(STYLE))
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("returns the hints alone for an ABSENT prompt", () => {
|
|
198
|
+
expect(composeVideoPromptText(undefined, { style: STYLE })).toBe(
|
|
199
|
+
getStylePromptHint(STYLE),
|
|
200
|
+
)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe("composeVideoPromptText — the dedupe invariant", () => {
|
|
205
|
+
// The five legacy keys address a WHOLE catalog, so they are not aliases of
|
|
206
|
+
// their canonical counterparts. Overlap is resolved by exact-clause dedupe,
|
|
207
|
+
// which suppresses a repeated clause without suppressing a different id.
|
|
208
|
+
it("emits ONE clause when a legacy and a canonical key carry the SAME id", () => {
|
|
209
|
+
expect(
|
|
210
|
+
composeVideoPromptText("a knight", { framingId: SHOT_SIZE, shotSize: SHOT_SIZE }),
|
|
211
|
+
).toBe(`a knight. ${getFramingPromptHint(SHOT_SIZE)}`)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it("emits BOTH clauses for two DIFFERENT ids of one catalog", () => {
|
|
215
|
+
// The case an alias table would have wrongly collapsed: `lightingId` is
|
|
216
|
+
// whole-catalog, so a time-of-day pick beside a lighting-style pick is two
|
|
217
|
+
// legitimate selections.
|
|
218
|
+
const out = composeVideoPromptText("a knight", {
|
|
219
|
+
lightingStyle: LIGHTING_STYLE,
|
|
220
|
+
lightingId: TIME_OF_DAY,
|
|
221
|
+
})
|
|
222
|
+
expect(out).toBe(
|
|
223
|
+
`a knight. ${getLightingPromptHint(LIGHTING_STYLE)}. ${getLightingPromptHint(TIME_OF_DAY)}`,
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* ORDER TOTALITY — every video-surface dimension, folded in one call.
|
|
230
|
+
*
|
|
231
|
+
* The fixture is keyed in `directionFieldsForSurface("video")` order and pinned
|
|
232
|
+
* against it, so adding, removing or reordering a video row fails HERE as well
|
|
233
|
+
* as in the registry test. Each id was chosen to render a clause distinct from
|
|
234
|
+
* every other row's, so the dedupe pass cannot mask a mis-ordering.
|
|
235
|
+
*/
|
|
236
|
+
const EVERY_VIDEO_DIMENSION: Record<string, string> = {
|
|
237
|
+
cameraMotion: "static",
|
|
238
|
+
shotSize: "extreme-wide-shot",
|
|
239
|
+
angle: "eye-level",
|
|
240
|
+
coverage: "single",
|
|
241
|
+
composition: "rule-of-thirds",
|
|
242
|
+
vantage: "front-on",
|
|
243
|
+
pose: "standing-upright",
|
|
244
|
+
compositionEffect: "bursting-through-frame",
|
|
245
|
+
cameraFormat: "35mm-film",
|
|
246
|
+
lens: "ultra-wide-14mm",
|
|
247
|
+
timeOfDay: "dawn",
|
|
248
|
+
lightingStyle: "three-point",
|
|
249
|
+
lightingDirection: "front",
|
|
250
|
+
lightingRatio: "ratio-1-1",
|
|
251
|
+
colorTemperature: "temp-2700k",
|
|
252
|
+
colorLook: "warm",
|
|
253
|
+
atmosphere: "clear",
|
|
254
|
+
style: "3d-render",
|
|
255
|
+
mood: "happy",
|
|
256
|
+
aesthetic: "y2k",
|
|
257
|
+
setting: "coffee-shop",
|
|
258
|
+
era: "1920s-flapper",
|
|
259
|
+
backdrop: "white-seamless",
|
|
260
|
+
actionFx: "earthquake-tremor",
|
|
261
|
+
temporalSpeed: "real-time",
|
|
262
|
+
temporalFreeze: "full-freeze",
|
|
263
|
+
temporalDirection: "forward",
|
|
264
|
+
temporalShutter: "long-exposure",
|
|
265
|
+
transition: "none",
|
|
266
|
+
loopSubject: "aurora",
|
|
267
|
+
framingId: "wide-shot",
|
|
268
|
+
framingAngleId: "medium-wide-shot",
|
|
269
|
+
lightingId: "sunrise",
|
|
270
|
+
lensId: "wide-24mm",
|
|
271
|
+
cameraFormatId: "16mm-film",
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
describe("composeVideoPromptText — order totality over every video dimension", () => {
|
|
275
|
+
it("covers exactly the video surface, in table order", () => {
|
|
276
|
+
expect(Object.keys(EVERY_VIDEO_DIMENSION)).toEqual(
|
|
277
|
+
directionFieldsForSurface("video").map((f) => f.key),
|
|
278
|
+
)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it("resolves every fixture id to a real clause", () => {
|
|
282
|
+
for (const [key, id] of Object.entries(EVERY_VIDEO_DIMENSION)) {
|
|
283
|
+
expect(composeVideoPromptText("", { [key]: id }), `${key}=${id}`).not.toBe("")
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("folds all 35 dimensions in registry order, one clause each", () => {
|
|
288
|
+
// Per-dimension renders, composed in isolation through the same public
|
|
289
|
+
// entry point, then concatenated in table order: the whole fold must equal
|
|
290
|
+
// exactly that. Any reorder, drop or duplicate shows up as a diff.
|
|
291
|
+
const expected = Object.entries(EVERY_VIDEO_DIMENSION).map(
|
|
292
|
+
([key, id]) => composeVideoPromptText("", { [key]: id })!,
|
|
293
|
+
)
|
|
294
|
+
expect(new Set(expected).size, "fixture ids must render distinct clauses").toBe(
|
|
295
|
+
expected.length,
|
|
296
|
+
)
|
|
297
|
+
expect(composeVideoPromptText("a knight", EVERY_VIDEO_DIMENSION)).toBe(
|
|
298
|
+
["a knight", ...expected].join(". "),
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
@@ -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
|
+
})
|