@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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
getRegisteredPickerCatalogs,
|
|
4
|
+
type PickerCatalog,
|
|
5
|
+
type PickerOption,
|
|
6
|
+
} from "../picker-catalogs.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* THE GUARD THAT MAKES "FOLD BEFORE THE REFERENCE RESOLVER" SAFE.
|
|
10
|
+
*
|
|
11
|
+
* `composeVideoPromptText` folds catalog text into the prompt BODY *before*
|
|
12
|
+
* `resolveVideoReferenceCore` runs (the look/motion description has to be
|
|
13
|
+
* inside the body the resolver frames, not appended after the identity
|
|
14
|
+
* directives). The consequence is that catalog text is then scanned by three
|
|
15
|
+
* passes that treat certain substrings as INPUT GRAMMAR:
|
|
16
|
+
*
|
|
17
|
+
* - the `{image:N}` / `{video:N}` / `{audio:N}` reference-slot expander,
|
|
18
|
+
* - the `{ref:<id>}` id-addressed reference-token pass,
|
|
19
|
+
* - the `@slug:N` character/named-image mention pass.
|
|
20
|
+
*
|
|
21
|
+
* A catalog whose text happened to contain one of those shapes would be
|
|
22
|
+
* rewritten by a pass that was never meant to see it — and, worse, could change
|
|
23
|
+
* the ASSEMBLED REFERENCE COUNT, which is exactly the quantity MiniMax-H3
|
|
24
|
+
* credit prediction reserves against. That is the one theoretical coupling
|
|
25
|
+
* between this text-only fold and pricing, and those three patterns are what
|
|
26
|
+
* close it.
|
|
27
|
+
*
|
|
28
|
+
* A fourth pattern is scanned for HYGIENE, not pricing: the resolver's OUTPUT
|
|
29
|
+
* binding form `@image_N` / `@video_N` / `@audio_N`. Nothing re-parses that
|
|
30
|
+
* shape, so it cannot move the assembled reference count — but a catalog
|
|
31
|
+
* emitting one would ship a binding directive to the model that binds to
|
|
32
|
+
* nothing.
|
|
33
|
+
*
|
|
34
|
+
* Scope is deliberately TOTAL rather than "the direction dimensions": it runs
|
|
35
|
+
* over every registered picker catalog (pack-composed, so a deployment's own
|
|
36
|
+
* pack is covered too) and over EVERY string a fold can inject — the full
|
|
37
|
+
* hint, the resolved compact term, AND the label. Labels are not decoration
|
|
38
|
+
* here: the multi-pick blend renderers weave them straight into the clause
|
|
39
|
+
* (`buildMoodHint`'s "with a {label} and {label} expression", `buildAestheticHints`'
|
|
40
|
+
* "{label} + {label} aesthetic blend", `buildPhotographerHints`' "blended visual
|
|
41
|
+
* language of {label} and {label}"), and mood + aesthetic are both
|
|
42
|
+
* `surface: "both"` with `maxPicks: 2`. Totality also has to survive promotion:
|
|
43
|
+
* a dimension can join the direction channel at any time, and the guard must
|
|
44
|
+
* already hold when it does.
|
|
45
|
+
*
|
|
46
|
+
* A failure here is a CATALOG fix (reword the entry), never a fold-site change.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** The reference-slot grammar (`video-reference-resolver.ts`'s `REFERENCE_TOKEN_RE`, `i`-flagged there). */
|
|
50
|
+
const SLOT_TOKEN = /\{(?:image|video|audio):\d+/i
|
|
51
|
+
/** The id-addressed reference token (`ref-id-tokens.ts`'s `HAS_REF_ID_TOKEN_RE`, `i`-flagged there). */
|
|
52
|
+
const REF_ID_TOKEN = /\{ref:/i
|
|
53
|
+
/**
|
|
54
|
+
* A character / named-image mention (`@kira:1`), boundary-guarded like the pass.
|
|
55
|
+
*
|
|
56
|
+
* `i`-flagged even though `findCharacterMentionTokens` is NOT: `buildMoodHint`
|
|
57
|
+
* folds `label.toLowerCase()`, so an upper-case mention-shaped label reaches the
|
|
58
|
+
* prompt lower-cased — i.e. as live grammar. Scanning case-insensitively is
|
|
59
|
+
* strictly the safe side; do not "correct" this to match the pass.
|
|
60
|
+
*/
|
|
61
|
+
const MENTION = /(?:^|[^A-Za-z0-9])@[a-z][a-z0-9-]*:\d+/i
|
|
62
|
+
/** The resolver's OUTPUT binding form (`video-reference-resolver.ts` emits `@${kind}_${n}`). */
|
|
63
|
+
const BINDING_FORM = /@(?:image|video|audio)_\d+/i
|
|
64
|
+
|
|
65
|
+
const FORBIDDEN: ReadonlyArray<{ name: string; re: RegExp }> = [
|
|
66
|
+
{ name: "reference slot token ({image:N} / {video:N} / {audio:N})", re: SLOT_TOKEN },
|
|
67
|
+
{ name: "id-addressed reference token ({ref:…})", re: REF_ID_TOKEN },
|
|
68
|
+
{ name: "character/named-image mention (@slug:N)", re: MENTION },
|
|
69
|
+
{ name: "reference binding form (@image_N / @video_N / @audio_N)", re: BINDING_FORM },
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
/** Every option of a catalog, single-dim and multi-dim alike. */
|
|
73
|
+
function allOptions(catalog: PickerCatalog): ReadonlyArray<PickerOption> {
|
|
74
|
+
return [
|
|
75
|
+
...(catalog.options ?? []),
|
|
76
|
+
...(catalog.dimensions ?? []).flatMap((d) => d.options),
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("direction hint token safety", () => {
|
|
81
|
+
const catalogs = getRegisteredPickerCatalogs()
|
|
82
|
+
|
|
83
|
+
it("has catalogs to check (the guard must not pass vacuously)", () => {
|
|
84
|
+
expect(catalogs.length).toBeGreaterThan(0)
|
|
85
|
+
expect(catalogs.reduce((n, c) => n + allOptions(c).length, 0)).toBeGreaterThan(1000)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("emits no reference-grammar token from any catalog hint, term or label", () => {
|
|
89
|
+
const offenders: string[] = []
|
|
90
|
+
for (const catalog of catalogs) {
|
|
91
|
+
for (const option of allOptions(catalog)) {
|
|
92
|
+
// `term` is already RESOLVED by the projection (`resolveTerm`), so this
|
|
93
|
+
// checks exactly the string a compact-mode fold would inject. `label`
|
|
94
|
+
// is injected verbatim by the multi-pick blend renderers (see the
|
|
95
|
+
// header). `description` is deliberately NOT scanned — no render path
|
|
96
|
+
// folds it into the prompt.
|
|
97
|
+
for (const [field, text] of [
|
|
98
|
+
["promptHint", option.promptHint],
|
|
99
|
+
["term", option.term],
|
|
100
|
+
["label", option.label],
|
|
101
|
+
] as const) {
|
|
102
|
+
if (!text) continue
|
|
103
|
+
for (const { name, re } of FORBIDDEN) {
|
|
104
|
+
if (re.test(text)) {
|
|
105
|
+
offenders.push(`${catalog.nodeType} • ${option.id} • ${field}: ${name}`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
expect(offenders).toEqual([])
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
DIRECTION_FIELDS,
|
|
4
|
+
DIRECTION_KEYS,
|
|
5
|
+
DIRECTION_ARRAY_CEILING,
|
|
6
|
+
IMAGE_HINT_MODE_DEFAULT,
|
|
7
|
+
VIDEO_HINT_MODE_DEFAULT,
|
|
8
|
+
directionFieldsForSurface,
|
|
9
|
+
modeForFamily,
|
|
10
|
+
renderDirectionHints,
|
|
11
|
+
} from "../direction-registry.js"
|
|
12
|
+
import {
|
|
13
|
+
buildFramingHints,
|
|
14
|
+
getFramingPromptHint,
|
|
15
|
+
getFramingTerm,
|
|
16
|
+
getFramingCategoryLimit,
|
|
17
|
+
FRAMING_FIELD_BY_CATEGORY,
|
|
18
|
+
type FramingCategory,
|
|
19
|
+
} from "../framing.js"
|
|
20
|
+
import { buildLightingHints, getLightingPromptHint } from "../lighting.js"
|
|
21
|
+
import { buildExposureHints } from "../exposure-settings.js"
|
|
22
|
+
import { getLensPromptHint } from "../lens.js"
|
|
23
|
+
import { getCameraFormatPromptHint } from "../camera-format.js"
|
|
24
|
+
import { buildMoodHints } from "../mood.js"
|
|
25
|
+
import { buildAestheticHints } from "../aesthetic.js"
|
|
26
|
+
import { buildPhotographerHints } from "../photographer.js"
|
|
27
|
+
import { buildAtmosphereHints } from "../atmosphere.js"
|
|
28
|
+
import { getStylePromptHint, getStyleTerm } from "../style.js"
|
|
29
|
+
import { getCameraMotionPromptHint, getCameraMotionTerm } from "../camera-motions.js"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The direction registry is the platform-owned contract for the flat
|
|
33
|
+
* `direction` wire channel: WHICH dimensions ride it, in WHAT order they fold,
|
|
34
|
+
* and HOW each catalog renders its selection. Every assertion here is a pin on
|
|
35
|
+
* that contract — a failure means a reorder / retable was intentional and the
|
|
36
|
+
* changeset has to say so.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const IMAGE = { surface: "image" } as const
|
|
40
|
+
const VIDEO = { surface: "video" } as const
|
|
41
|
+
|
|
42
|
+
// Real catalog ids (every getter returns "" on a miss, so a fake id would make
|
|
43
|
+
// most of these assertions vacuously pass).
|
|
44
|
+
const NO_SUCH_ID = "__no_such_id__"
|
|
45
|
+
|
|
46
|
+
describe("DIRECTION_FIELDS — table integrity", () => {
|
|
47
|
+
it("has unique keys", () => {
|
|
48
|
+
const keys = DIRECTION_FIELDS.map((f) => f.key)
|
|
49
|
+
expect(new Set(keys).size).toBe(keys.length)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("gives every row a positive maxPicks and a render function", () => {
|
|
53
|
+
for (const spec of DIRECTION_FIELDS) {
|
|
54
|
+
expect(spec.maxPicks, spec.key).toBeGreaterThanOrEqual(1)
|
|
55
|
+
expect(typeof spec.render, spec.key).toBe("function")
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("exports DIRECTION_KEYS in table order", () => {
|
|
60
|
+
expect(DIRECTION_KEYS).toEqual(DIRECTION_FIELDS.map((f) => f.key))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// ── THE ORDER PIN ────────────────────────────────────────────────────────
|
|
64
|
+
// The canonical fold order, exported so a client preview cannot drift from
|
|
65
|
+
// the server. Camera motion leads; the five pre-registry keys form the
|
|
66
|
+
// LEGACY BLOCK at the tail (see the legacy-tail pin below).
|
|
67
|
+
it("pins the canonical fold order (42 keys)", () => {
|
|
68
|
+
expect(DIRECTION_KEYS).toEqual([
|
|
69
|
+
"cameraMotion",
|
|
70
|
+
"shotSize",
|
|
71
|
+
"angle",
|
|
72
|
+
"coverage",
|
|
73
|
+
"composition",
|
|
74
|
+
"vantage",
|
|
75
|
+
"pose",
|
|
76
|
+
"compositionEffect",
|
|
77
|
+
"cameraFormat",
|
|
78
|
+
"lens",
|
|
79
|
+
"aperture",
|
|
80
|
+
"shutterSpeed",
|
|
81
|
+
"isoValue",
|
|
82
|
+
"timeOfDay",
|
|
83
|
+
"lightingStyle",
|
|
84
|
+
"lightingDirection",
|
|
85
|
+
"lightingRatio",
|
|
86
|
+
"colorTemperature",
|
|
87
|
+
"colorLook",
|
|
88
|
+
"atmosphere",
|
|
89
|
+
"postProcess",
|
|
90
|
+
"style",
|
|
91
|
+
"mood",
|
|
92
|
+
"aesthetic",
|
|
93
|
+
"photoGenre",
|
|
94
|
+
"photographer",
|
|
95
|
+
"renderQuality",
|
|
96
|
+
"setting",
|
|
97
|
+
"era",
|
|
98
|
+
"backdrop",
|
|
99
|
+
"actionFx",
|
|
100
|
+
"temporalSpeed",
|
|
101
|
+
"temporalFreeze",
|
|
102
|
+
"temporalDirection",
|
|
103
|
+
"temporalShutter",
|
|
104
|
+
"transition",
|
|
105
|
+
"loopSubject",
|
|
106
|
+
"framingId",
|
|
107
|
+
"framingAngleId",
|
|
108
|
+
"lightingId",
|
|
109
|
+
"lensId",
|
|
110
|
+
"cameraFormatId",
|
|
111
|
+
])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// ── THE LEGACY-TAIL PIN (the darkness guarantee) ─────────────────────────
|
|
115
|
+
// This is what makes "no prompt text changed for any EXISTING caller" true:
|
|
116
|
+
// the five pre-registry keys sit LAST, in the exact order the old inlined
|
|
117
|
+
// `composePromptText` folded them. Re-sorting the table must fail loudly.
|
|
118
|
+
it("keeps the five pre-registry keys last, in their original fold order", () => {
|
|
119
|
+
expect(DIRECTION_KEYS.slice(-5)).toEqual([
|
|
120
|
+
"framingId",
|
|
121
|
+
"framingAngleId",
|
|
122
|
+
"lightingId",
|
|
123
|
+
"lensId",
|
|
124
|
+
"cameraFormatId",
|
|
125
|
+
])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("folds a legacy-only direction in the original order", () => {
|
|
129
|
+
const hints = renderDirectionHints(
|
|
130
|
+
{
|
|
131
|
+
framingId: "wide-shot",
|
|
132
|
+
framingAngleId: "low-angle",
|
|
133
|
+
lightingId: "dawn",
|
|
134
|
+
lensId: "wide-24mm",
|
|
135
|
+
cameraFormatId: "16mm-film",
|
|
136
|
+
},
|
|
137
|
+
IMAGE,
|
|
138
|
+
)
|
|
139
|
+
expect(hints).toEqual([
|
|
140
|
+
getFramingPromptHint("wide-shot"),
|
|
141
|
+
getFramingPromptHint("low-angle"),
|
|
142
|
+
getLightingPromptHint("dawn"),
|
|
143
|
+
getLensPromptHint("wide-24mm"),
|
|
144
|
+
getCameraFormatPromptHint("16mm-film"),
|
|
145
|
+
])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// ── COUNT PIN ────────────────────────────────────────────────────────────
|
|
149
|
+
it("pins the surface split", () => {
|
|
150
|
+
expect(DIRECTION_FIELDS).toHaveLength(42)
|
|
151
|
+
expect(DIRECTION_FIELDS.filter((f) => f.surface === "both")).toHaveLength(27)
|
|
152
|
+
expect(DIRECTION_FIELDS.filter((f) => f.surface === "image")).toHaveLength(7)
|
|
153
|
+
expect(DIRECTION_FIELDS.filter((f) => f.surface === "video")).toHaveLength(8)
|
|
154
|
+
expect(directionFieldsForSurface("image")).toHaveLength(34)
|
|
155
|
+
expect(directionFieldsForSurface("video")).toHaveLength(35)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("excludes characterFx (it needs a per-shot composer, not a bare id)", () => {
|
|
159
|
+
expect(DIRECTION_KEYS).not.toContain("characterFx")
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("exposes a wire array ceiling above every per-row cap", () => {
|
|
163
|
+
for (const spec of DIRECTION_FIELDS) {
|
|
164
|
+
expect(spec.maxPicks, spec.key).toBeLessThanOrEqual(DIRECTION_ARRAY_CEILING)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ── CANONICAL-CAP PIN ────────────────────────────────────────────────────
|
|
169
|
+
// `maxPicks` is written as a literal per row (the table stays readable, and
|
|
170
|
+
// most catalogs have no exported cap to derive from), but where the platform
|
|
171
|
+
// DOES own a canonical per-dimension limit the two must agree. Framing is
|
|
172
|
+
// that case: `getFramingCategoryLimit` is the source of truth the pickers
|
|
173
|
+
// read, so raising it (e.g. composition 2 → 3) must fail here rather than
|
|
174
|
+
// leave the registry silently slicing to the stale number.
|
|
175
|
+
it("matches the canonical framing category limits", () => {
|
|
176
|
+
const byKey = new Map(DIRECTION_FIELDS.map((f) => [f.key as string, f.maxPicks]))
|
|
177
|
+
for (const [category, field] of Object.entries(FRAMING_FIELD_BY_CATEGORY)) {
|
|
178
|
+
expect(byKey.get(field), field).toBe(getFramingCategoryLimit(category as FramingCategory))
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe("renderDirectionHints — totality", () => {
|
|
184
|
+
it("renders nothing for an empty id list, in both modes", () => {
|
|
185
|
+
for (const spec of DIRECTION_FIELDS) {
|
|
186
|
+
expect(spec.render([], "full"), spec.key).toEqual([])
|
|
187
|
+
expect(spec.render([], "compact"), spec.key).toEqual([])
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("renders nothing for an unknown id, in both modes (never throws)", () => {
|
|
192
|
+
for (const spec of DIRECTION_FIELDS) {
|
|
193
|
+
expect(spec.render([NO_SUCH_ID], "full"), spec.key).toEqual([])
|
|
194
|
+
expect(spec.render([NO_SUCH_ID], "compact"), spec.key).toEqual([])
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("returns [] for undefined / {} / empty values", () => {
|
|
199
|
+
expect(renderDirectionHints(undefined, IMAGE)).toEqual([])
|
|
200
|
+
expect(renderDirectionHints({}, IMAGE)).toEqual([])
|
|
201
|
+
expect(renderDirectionHints({ style: "" }, IMAGE)).toEqual([])
|
|
202
|
+
expect(renderDirectionHints({ mood: [] }, IMAGE)).toEqual([])
|
|
203
|
+
expect(renderDirectionHints({ style: NO_SUCH_ID }, IMAGE)).toEqual([])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("ignores wire keys that are not in the table", () => {
|
|
207
|
+
expect(
|
|
208
|
+
renderDirectionHints({ notAKey: "whatever" } as never, IMAGE),
|
|
209
|
+
).toEqual([])
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe("renderDirectionHints — surface filtering", () => {
|
|
214
|
+
it("makes a video-only key inert on the image surface", () => {
|
|
215
|
+
expect(renderDirectionHints({ cameraMotion: "handheld" }, IMAGE)).toEqual([])
|
|
216
|
+
expect(renderDirectionHints({ temporalSpeed: "slow-motion" }, IMAGE)).toEqual([])
|
|
217
|
+
expect(renderDirectionHints({ actionFx: "earthquake-tremor" }, IMAGE)).toEqual([])
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("makes an image-only key inert on the video surface", () => {
|
|
221
|
+
expect(renderDirectionHints({ aperture: "aperture-f1-4" }, VIDEO)).toEqual([])
|
|
222
|
+
expect(renderDirectionHints({ photographer: "tim-walker" }, VIDEO)).toEqual([])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("renders a `both` key on both surfaces", () => {
|
|
226
|
+
const expected = [getStylePromptHint("anime")]
|
|
227
|
+
expect(renderDirectionHints({ style: "anime" }, IMAGE)).toEqual(expected)
|
|
228
|
+
expect(renderDirectionHints({ style: "anime" }, VIDEO)).toEqual(expected)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("folds in table order regardless of the caller's object-literal order", () => {
|
|
232
|
+
const hints = renderDirectionHints({ style: "anime", shotSize: "wide-shot" }, IMAGE)
|
|
233
|
+
expect(hints).toEqual([getFramingPromptHint("wide-shot"), getStylePromptHint("anime")])
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe("renderDirectionHints — multiplicity", () => {
|
|
238
|
+
it("caps a multi-pick dimension at its maxPicks", () => {
|
|
239
|
+
const capped = renderDirectionHints({ mood: ["happy", "joyful", "relieved"] }, IMAGE)
|
|
240
|
+
expect(capped).toEqual(buildMoodHints({ mood: ["happy", "joyful"] }, "full"))
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("tolerates an array on a single-pick key (top pick wins, no throw)", () => {
|
|
244
|
+
expect(renderDirectionHints({ style: ["anime", "3d-render"] }, IMAGE)).toEqual([
|
|
245
|
+
getStylePromptHint("anime"),
|
|
246
|
+
])
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it("de-dupes repeated ids inside one key", () => {
|
|
250
|
+
expect(renderDirectionHints({ atmosphere: ["clear", "clear"] }, IMAGE)).toEqual(
|
|
251
|
+
buildAtmosphereHints("clear", "full"),
|
|
252
|
+
)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("stops scanning a key once maxPicks unique ids are in hand", () => {
|
|
256
|
+
// The dedupe is an `includes` scan, so scanning past `maxPicks` is
|
|
257
|
+
// quadratic in an array length no caller bounds for us — and one caller
|
|
258
|
+
// (`readDirectionFields`) reads untrusted persisted JSONB. Bailing is
|
|
259
|
+
// semantics-preserving: the result must equal the one from the
|
|
260
|
+
// already-sufficient prefix. No timing assertion — the default vitest
|
|
261
|
+
// timeout is the guard.
|
|
262
|
+
const flood = ["happy", "joyful", ...Array.from({ length: 200_000 }, (_, i) => `id-${i}`)]
|
|
263
|
+
expect(renderDirectionHints({ mood: flood }, IMAGE)).toEqual(
|
|
264
|
+
buildMoodHints({ mood: ["happy", "joyful"] }, "full"),
|
|
265
|
+
)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// ── THE DEDUPE INVARIANT ───────────────────────────────────────────────────
|
|
270
|
+
// The five legacy whole-catalog keys address the SAME catalogs as their
|
|
271
|
+
// canonical counterparts but are NOT aliases of one category — so overlap is
|
|
272
|
+
// resolved by exact-clause dedupe, never by an alias table.
|
|
273
|
+
describe("renderDirectionHints — dedupe invariant", () => {
|
|
274
|
+
it("emits ONE clause when a legacy key and its canonical counterpart carry the same id", () => {
|
|
275
|
+
expect(renderDirectionHints({ framingId: "wide-shot", shotSize: "wide-shot" }, IMAGE)).toEqual([
|
|
276
|
+
getFramingPromptHint("wide-shot"),
|
|
277
|
+
])
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it("emits BOTH clauses for two DIFFERENT ids of one catalog (what an alias table would have broken)", () => {
|
|
281
|
+
const hints = renderDirectionHints(
|
|
282
|
+
{ lightingId: "dawn", lightingStyle: "rembrandt" },
|
|
283
|
+
IMAGE,
|
|
284
|
+
)
|
|
285
|
+
expect(hints).toHaveLength(2)
|
|
286
|
+
expect(hints).toContain(getLightingPromptHint("dawn"))
|
|
287
|
+
expect(hints).toContain(getLightingPromptHint("rembrandt"))
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it("collapses the degenerate legacy case: the same id on two keys of one catalog", () => {
|
|
291
|
+
// The single documented byte difference for a pre-registry caller: this
|
|
292
|
+
// emitted the identical clause TWICE before the registry.
|
|
293
|
+
expect(
|
|
294
|
+
renderDirectionHints({ framingId: "wide-shot", framingAngleId: "wide-shot" }, IMAGE),
|
|
295
|
+
).toEqual([getFramingPromptHint("wide-shot")])
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
// ── BLEND SEMANTICS ────────────────────────────────────────────────────────
|
|
300
|
+
// Mood / Aesthetic / Photographer emit ONE blended clause for a multi-pick, not
|
|
301
|
+
// one paragraph per id. A naive per-id loop would silently regress this.
|
|
302
|
+
describe("renderDirectionHints — blend catalogs", () => {
|
|
303
|
+
it("blends two moods into the catalog's own single clause", () => {
|
|
304
|
+
const hints = renderDirectionHints({ mood: ["happy", "joyful"] }, IMAGE)
|
|
305
|
+
expect(hints).toEqual(buildMoodHints({ mood: ["happy", "joyful"] }, "full"))
|
|
306
|
+
expect(hints).toHaveLength(1)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it("blends two aesthetics through buildAestheticHints", () => {
|
|
310
|
+
const hints = renderDirectionHints({ aesthetic: ["y2k", "cottagecore"] }, IMAGE)
|
|
311
|
+
expect(hints).toHaveLength(1)
|
|
312
|
+
expect(hints).toEqual([buildAestheticHints(["y2k", "cottagecore"], "full")])
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it("blends two photographers through buildPhotographerHints", () => {
|
|
316
|
+
const hints = renderDirectionHints(
|
|
317
|
+
{ photographer: ["tim-walker", "paolo-roversi"] },
|
|
318
|
+
IMAGE,
|
|
319
|
+
)
|
|
320
|
+
expect(hints).toHaveLength(1)
|
|
321
|
+
expect(hints).toEqual([buildPhotographerHints(["tim-walker", "paolo-roversi"], "full")])
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// ── FAMILY EQUIVALENCE ─────────────────────────────────────────────────────
|
|
326
|
+
// Ties the flat per-category keys to the canonical family walks FOREVER: the
|
|
327
|
+
// registry must render exactly what a wired picker node of that family does.
|
|
328
|
+
describe("renderDirectionHints — family equivalence with the canonical builders", () => {
|
|
329
|
+
it("matches buildFramingHints for the five framing keys", () => {
|
|
330
|
+
const framing = {
|
|
331
|
+
shotSize: "wide-shot",
|
|
332
|
+
angle: "low-angle",
|
|
333
|
+
coverage: "two-shot",
|
|
334
|
+
composition: "rule-of-thirds",
|
|
335
|
+
vantage: "profile-left",
|
|
336
|
+
}
|
|
337
|
+
expect(renderDirectionHints(framing, IMAGE)).toEqual(
|
|
338
|
+
buildFramingHints(framing, false, "full"),
|
|
339
|
+
)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it("matches buildLightingHints for the five lighting keys", () => {
|
|
343
|
+
const lighting = {
|
|
344
|
+
timeOfDay: "dawn",
|
|
345
|
+
lightingStyle: "rembrandt",
|
|
346
|
+
lightingDirection: "side",
|
|
347
|
+
lightingRatio: "ratio-1-2",
|
|
348
|
+
colorTemperature: "temp-3200k",
|
|
349
|
+
}
|
|
350
|
+
expect(renderDirectionHints(lighting, IMAGE)).toEqual(buildLightingHints(lighting, "full"))
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it("matches buildExposureHints for the three exposure keys", () => {
|
|
354
|
+
const exposure = {
|
|
355
|
+
aperture: "aperture-f1-4",
|
|
356
|
+
shutterSpeed: "shutter-1-60",
|
|
357
|
+
isoValue: "iso-400",
|
|
358
|
+
}
|
|
359
|
+
expect(renderDirectionHints(exposure, IMAGE)).toEqual(buildExposureHints(exposure, "full"))
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
describe("hint modes", () => {
|
|
364
|
+
it("resolves a flat mode for both families", () => {
|
|
365
|
+
expect(modeForFamily("compact", "look")).toBe("compact")
|
|
366
|
+
expect(modeForFamily("compact", "motion")).toBe("compact")
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it("resolves a split mode per family", () => {
|
|
370
|
+
expect(modeForFamily(VIDEO_HINT_MODE_DEFAULT, "look")).toBe("full")
|
|
371
|
+
expect(modeForFamily(VIDEO_HINT_MODE_DEFAULT, "motion")).toBe("compact")
|
|
372
|
+
expect(IMAGE_HINT_MODE_DEFAULT).toBe("full")
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it("emits compact terms in compact mode", () => {
|
|
376
|
+
expect(renderDirectionHints({ style: "anime" }, { ...IMAGE, mode: "compact" })).toEqual([
|
|
377
|
+
getStyleTerm("anime"),
|
|
378
|
+
])
|
|
379
|
+
expect(
|
|
380
|
+
renderDirectionHints({ shotSize: "wide-shot" }, { ...IMAGE, mode: "compact" }),
|
|
381
|
+
).toEqual([getFramingTerm("wide-shot")])
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it("applies the split mode per family on a video fold", () => {
|
|
385
|
+
const hints = renderDirectionHints(
|
|
386
|
+
{ cameraMotion: "handheld", style: "anime" },
|
|
387
|
+
{ ...VIDEO, mode: VIDEO_HINT_MODE_DEFAULT },
|
|
388
|
+
)
|
|
389
|
+
// cameraMotion leads (motion → compact term); style follows (look → full).
|
|
390
|
+
expect(hints).toEqual([getCameraMotionTerm("handheld"), getStylePromptHint("anime")])
|
|
391
|
+
expect(hints[0]).not.toBe(getCameraMotionPromptHint("handheld"))
|
|
392
|
+
})
|
|
393
|
+
})
|