@nodaro/prompts 1.11.0 → 1.12.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 +383 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +516 -28
- package/dist/index.d.ts +516 -28
- package/dist/index.js +368 -51
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
- package/src/__tests__/animal-getters-parity.test.ts +82 -0
- package/src/__tests__/assemble-image-input-cap.test.ts +212 -0
- package/src/__tests__/assemble-video-input-cap.test.ts +356 -0
- package/src/__tests__/entity-convergence-image.test.ts +374 -0
- package/src/__tests__/location-convergence-image.test.ts +29 -1
- package/src/__tests__/location-default-role-image.test.ts +166 -0
- package/src/__tests__/mention-splice-spacing.test.ts +257 -0
- package/src/__tests__/read-node-subject.test.ts +140 -0
- package/src/__tests__/subject-fold.test.ts +232 -0
- package/src/__tests__/subject-registry.test.ts +312 -0
- package/src/assemble-image-input.ts +133 -30
- package/src/assemble-video-input.ts +173 -18
- package/src/direction-registry.ts +18 -1
- package/src/hint-shedding.ts +68 -0
- package/src/index.ts +2 -0
- package/src/parameter-prompt-hint.ts +8 -7
- package/src/picker-catalogs.ts +14 -7
- package/src/prompt-builder.ts +544 -61
- package/src/read-node-direction.ts +60 -1
- package/src/subject-registry.ts +464 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Narrow readers turning UNTRUSTED persisted node data (`workflows.nodes`
|
|
3
3
|
* JSONB — import, MCP write, node preset, a Studio-emitted graph) into the
|
|
4
|
-
* typed `direction` / `structured` levers `assembleImageInput`
|
|
4
|
+
* typed `subject` / `direction` / `structured` levers `assembleImageInput`
|
|
5
|
+
* accepts.
|
|
5
6
|
* `buildPayload` has no zod and workflow writes are
|
|
6
7
|
* `z.record(z.string(), z.unknown())`, so this blob may have been written years
|
|
7
8
|
* ago by any client.
|
|
@@ -46,6 +47,13 @@ import {
|
|
|
46
47
|
DIRECTION_ID_MAX_CHARS,
|
|
47
48
|
type DirectionFields,
|
|
48
49
|
} from "./direction-registry.js"
|
|
50
|
+
import {
|
|
51
|
+
SUBJECT_ARRAY_CEILING,
|
|
52
|
+
SUBJECT_CUSTOM_AGE_KEY,
|
|
53
|
+
SUBJECT_ID_MAX_CHARS,
|
|
54
|
+
getRegisteredSubjectKeys,
|
|
55
|
+
type SubjectFields,
|
|
56
|
+
} from "./subject-registry.js"
|
|
49
57
|
import type { StructuredPromptFields } from "./prompt-builder-structured-fields.js"
|
|
50
58
|
|
|
51
59
|
/**
|
|
@@ -87,6 +95,57 @@ export function readDirectionFields(value: unknown): DirectionFields | undefined
|
|
|
87
95
|
return Object.keys(out).length > 0 ? (out as DirectionFields) : undefined
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
// ── subject ──────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read a node's stored SUBJECT ids — the flat Person / Styling / prop bag the
|
|
102
|
+
* `subject` channel carries. Same three contracts as `readDirectionFields`:
|
|
103
|
+
* drop-never-throw, `undefined` never `{}`, and bounds shared with the wire
|
|
104
|
+
* door by CONSTANT (`SUBJECT_ID_MAX_CHARS` / `SUBJECT_ARRAY_CEILING`, which are
|
|
105
|
+
* defined AS the direction constants) so a body the route accepts and the same
|
|
106
|
+
* node re-run from the canvas cannot disagree about which strings are ids.
|
|
107
|
+
*
|
|
108
|
+
* DERIVED from `getRegisteredSubjectKeys()`, never a hand-authored field list —
|
|
109
|
+
* that is the whole lesson of `readStructuredFields` below, whose hand table is
|
|
110
|
+
* only viable because `StructuredPromptFields` is a small hand-authored type.
|
|
111
|
+
* The subject vocabulary is ~54 catalog-derived fields PLUS deployment-registered
|
|
112
|
+
* pack dimensions unknown at compile time, so it is read from the registry at
|
|
113
|
+
* call time. Iterating the KEY SET (never `Object.keys(value)`) is also what
|
|
114
|
+
* keeps an adversarial blob's key count off the hot path.
|
|
115
|
+
*
|
|
116
|
+
* The SEMANTIC per-dimension cap stays the renderer's job
|
|
117
|
+
* (`normalizeSubjectFields`), exactly as `maxPicks` does for direction: this
|
|
118
|
+
* reader bounds cardinality only. `customAge` is the one number on the wire and
|
|
119
|
+
* is passed through finite-only — the renderer clamps it.
|
|
120
|
+
*/
|
|
121
|
+
export function readSubjectFields(value: unknown): SubjectFields | undefined {
|
|
122
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
|
|
123
|
+
const src = value as Record<string, unknown>
|
|
124
|
+
const out: Record<string, string | string[] | number> = {}
|
|
125
|
+
for (const key of getRegisteredSubjectKeys()) {
|
|
126
|
+
const v = src[key]
|
|
127
|
+
if (key === SUBJECT_CUSTOM_AGE_KEY) {
|
|
128
|
+
if (typeof v === "number" && Number.isFinite(v)) out[key] = v
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if (typeof v === "string") {
|
|
132
|
+
if (v.length > 0 && v.length <= SUBJECT_ID_MAX_CHARS) out[key] = v
|
|
133
|
+
} else if (Array.isArray(v)) {
|
|
134
|
+
// Junk is filtered BEFORE the cap, so valid ids sitting behind malformed
|
|
135
|
+
// entries survive rather than being crowded out by them.
|
|
136
|
+
const kept: string[] = []
|
|
137
|
+
for (const x of v) {
|
|
138
|
+
if (kept.length >= SUBJECT_ARRAY_CEILING) break
|
|
139
|
+
if (typeof x === "string" && x.length > 0 && x.length <= SUBJECT_ID_MAX_CHARS) {
|
|
140
|
+
kept.push(x)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (kept.length > 0) out[key] = kept
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return Object.keys(out).length > 0 ? (out as SubjectFields) : undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
90
149
|
// ── structured ───────────────────────────────────────────────────────────────
|
|
91
150
|
|
|
92
151
|
type FieldKind = "string" | "number" | "gender"
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SUBJECT REGISTRY — the ordered table of every SUBJECT dimension the flat
|
|
3
|
+
* `subject` channel carries (who is in the shot: Person, Styling, and the three
|
|
4
|
+
* prop catalogs), plus the one renderer that folds it into prompt text.
|
|
5
|
+
*
|
|
6
|
+
* WHY A SECOND CHANNEL, NOT `StructuredPromptFields`: that type is FREE TEXT
|
|
7
|
+
* (`person.hair?: string` → "with {hair} hair"), its reader is a hand-authored
|
|
8
|
+
* field table whose totality is compile-time enforced by mapped types, its
|
|
9
|
+
* values are bounded as prose (200 chars), and it renders as headed sentences
|
|
10
|
+
* ("Subject: …. Style: …."). None of those survive contact with ~50 CATALOG IDS
|
|
11
|
+
* plus deployment-registered pack dimensions unknown at compile time, whose
|
|
12
|
+
* hints are authored as comma-joinable compound clauses. `direction-registry.ts`
|
|
13
|
+
* already has the four properties this channel needs — a platform-owned fold
|
|
14
|
+
* order exported for client preview parity, a DERIVED wire schema and node
|
|
15
|
+
* reader, unknown-key/unknown-id inertness, and shared bounds across both doors
|
|
16
|
+
* — so this module mirrors it. `direction-registry.ts` hands this scope off by
|
|
17
|
+
* name in its own header.
|
|
18
|
+
*
|
|
19
|
+
* WIRE VOCABULARY: the keys are the platform's OWN node-data field names — the
|
|
20
|
+
* ones `PERSON_FIELD_BY_DIMENSION` / `STYLING_FIELD_BY_DIMENSION` and the prop
|
|
21
|
+
* pickers' `valueField` already use (`hairBase`, `lipState`, `wardrobeState`,
|
|
22
|
+
* `heldProp`, `material`, `animal`, …). A canvas person/styling node stores
|
|
23
|
+
* exactly these, and every `build*Hints` already consumes them.
|
|
24
|
+
*
|
|
25
|
+
* THE WIRE IS A FLAT BAG, AND THE FLATNESS IS LOAD-BEARING (not a shortcut):
|
|
26
|
+
* `collectStylingFragments` reads `data.lipState` — a PERSON field — to skip
|
|
27
|
+
* `makeup-bold-lips` when `lip-state-bold-red` is already selected. That dedupe
|
|
28
|
+
* only fires for a consumer folding both pickers off ONE shared value map,
|
|
29
|
+
* which is precisely what this channel is. Nesting `person` / `styling` as
|
|
30
|
+
* sub-records would hand the styling builder a bag with no `lipState` in it and
|
|
31
|
+
* the lipstick clause would silently double. Hence: flat wire, and the group
|
|
32
|
+
* rows below receive the WHOLE normalized bag rather than a slice of it.
|
|
33
|
+
*
|
|
34
|
+
* PRE/POST FREE TEXT IS DELIBERATELY OFF THE WIRE (v1): `PersonValue` and
|
|
35
|
+
* `StylingValue` BOTH declare `preText`/`postText` and both `collect*Fragments`
|
|
36
|
+
* read them, so a shared flat bag would emit the same prose twice. They are the
|
|
37
|
+
* only genuine key collision in the set and are simply not `SUBJECT_KEYS`, so
|
|
38
|
+
* the normalizer drops them. Per-subject free text is the one future need that
|
|
39
|
+
* would force either nesting or suffixed keys (`personPreText` / …).
|
|
40
|
+
*
|
|
41
|
+
* IMPORT RULE (hard, inherited from the direction registry): this module
|
|
42
|
+
* imports only `get*PromptHint` / `get*Term` / `build*Hints` FUNCTIONS and the
|
|
43
|
+
* `*_FIELD_BY_DIMENSION` / `*_DIMENSION_ORDER` maps — never a raw UPPERCASE
|
|
44
|
+
* catalog array (`PEOPLE`, `STYLINGS`, `ANIMALS`). `catalog-funnel-ratchet.test.ts`
|
|
45
|
+
* derives its watch set from `picker-catalogs.ts`'s uppercase value imports and
|
|
46
|
+
* can only SHRINK, so a raw array import here would be a new offender — which
|
|
47
|
+
* is exactly why the animal getters live in `@nodaro/shared`. Nothing here
|
|
48
|
+
* reads an environment variable either (`content-free-contract.test.ts`):
|
|
49
|
+
* verbosity and surface are threaded parameters, never deployment state.
|
|
50
|
+
*
|
|
51
|
+
* DISJOINT FROM `direction` BY CONTRACT: `SUBJECT_KEYS ∩ DIRECTION_KEYS = ∅`,
|
|
52
|
+
* pinned by a test. `pose` rides `direction` even though the picker wiring files
|
|
53
|
+
* it under "Subject / Object"; keeping the two key sets disjoint is what stops
|
|
54
|
+
* one selection emitting two clauses.
|
|
55
|
+
*/
|
|
56
|
+
import type { PickerHintMode } from "./term.js"
|
|
57
|
+
import {
|
|
58
|
+
DIRECTION_ARRAY_CEILING,
|
|
59
|
+
DIRECTION_ID_MAX_CHARS,
|
|
60
|
+
} from "./direction-registry.js"
|
|
61
|
+
import {
|
|
62
|
+
buildPersonHints,
|
|
63
|
+
getPersonDimensionLimit,
|
|
64
|
+
PERSON_DIMENSION_ORDER,
|
|
65
|
+
PERSON_FIELD_BY_DIMENSION,
|
|
66
|
+
type PersonDimension,
|
|
67
|
+
type PersonValue,
|
|
68
|
+
} from "./person.js"
|
|
69
|
+
import {
|
|
70
|
+
getRegisteredPersonDimensionOrder,
|
|
71
|
+
getRegisteredPersonFieldByDimension,
|
|
72
|
+
} from "./person-packs.js"
|
|
73
|
+
import {
|
|
74
|
+
buildStylingHints,
|
|
75
|
+
getStylingDimensionLimit,
|
|
76
|
+
STYLING_DIMENSION_ORDER,
|
|
77
|
+
STYLING_FIELD_BY_DIMENSION,
|
|
78
|
+
type StylingDimension,
|
|
79
|
+
type StylingValue,
|
|
80
|
+
} from "./styling.js"
|
|
81
|
+
import { buildHeldPropHints } from "./held-prop.js"
|
|
82
|
+
import { buildMaterialHints } from "./materials.js"
|
|
83
|
+
import { getAnimalPromptHint, getAnimalTerm } from "@nodaro/shared"
|
|
84
|
+
|
|
85
|
+
/** Which generation stages fold a dimension (mirrors `DirectionSurface`). */
|
|
86
|
+
export type SubjectSurface = "image" | "video" | "both"
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Flat subject ids — the wire shape and the canvas node-data shape.
|
|
90
|
+
*
|
|
91
|
+
* Keys are the platform's own field names; a value is one id, a list of ids, or
|
|
92
|
+
* (for `customAge` alone) a number. Absent ≠ empty: a missing key means "no
|
|
93
|
+
* hint", never a default.
|
|
94
|
+
*/
|
|
95
|
+
export type SubjectFields = Readonly<Record<string, string | readonly string[] | number>>
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A fold row whose render consumes the WHOLE normalized bag and returns ONE
|
|
99
|
+
* already-joined clause.
|
|
100
|
+
*
|
|
101
|
+
* Two reasons this kind exists rather than one row per person/styling field:
|
|
102
|
+
* 1. the cross-catalog dedupe described in the module header needs the whole
|
|
103
|
+
* bag in one call;
|
|
104
|
+
* 2. these builders emit FRAGMENTS, not clauses — 30 person fragments handed
|
|
105
|
+
* to the `". "` prompt-hint join read as "a beautiful woman. in her 30s.
|
|
106
|
+
* East Asian." The catalogs' own grammar is a comma-joined compound clause,
|
|
107
|
+
* so the row joins with ", " and hands back a single piece.
|
|
108
|
+
*/
|
|
109
|
+
export interface SubjectGroupFieldSpec {
|
|
110
|
+
/** Fold-row id. NOT a wire key — a group row reads many of them. */
|
|
111
|
+
readonly key: string
|
|
112
|
+
readonly kind: "group"
|
|
113
|
+
readonly surface: SubjectSurface
|
|
114
|
+
/** The whole normalized bag in, one joined clause out (`[]` when empty). */
|
|
115
|
+
readonly render: (subject: SubjectFields, mode: PickerHintMode) => string[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A fold row that reads ONE wire key holding catalog ids (the prop catalogs). */
|
|
119
|
+
export interface SubjectIdsFieldSpec {
|
|
120
|
+
/** Wire key AND fold-row id — these rows are one key each. */
|
|
121
|
+
readonly key: string
|
|
122
|
+
readonly kind: "ids"
|
|
123
|
+
readonly surface: SubjectSurface
|
|
124
|
+
/** Ids honored. Extras are SLICED by the normalizer, never a 400. */
|
|
125
|
+
readonly maxPicks: number
|
|
126
|
+
readonly render: (ids: readonly string[], mode: PickerHintMode) => string[]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type SubjectFieldSpec = SubjectGroupFieldSpec | SubjectIdsFieldSpec
|
|
130
|
+
|
|
131
|
+
// ── Render adapters (module-private) ────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The shape both group builders accept. Their declared parameters are
|
|
135
|
+
* `Record<string, unknown> & PersonValue` and `… & StylingValue`; the
|
|
136
|
+
* intersection is assignable to both, so ONE adapter serves both rows without
|
|
137
|
+
* erasing the argument type — a builder signature change fails to typecheck
|
|
138
|
+
* here rather than silently passing the wrong bag.
|
|
139
|
+
*/
|
|
140
|
+
type SubjectBuilderData = Record<string, unknown> & PersonValue & StylingValue
|
|
141
|
+
|
|
142
|
+
/** A `build*Hints` that returns FRAGMENTS → one comma-joined clause. */
|
|
143
|
+
const viaFragmentBuilder =
|
|
144
|
+
(build: (data: SubjectBuilderData, mode: PickerHintMode) => string[]) =>
|
|
145
|
+
(subject: SubjectFields, mode: PickerHintMode): string[] => {
|
|
146
|
+
const clause = build(subject as unknown as SubjectBuilderData, mode)
|
|
147
|
+
.filter((s) => s.length > 0)
|
|
148
|
+
.join(", ")
|
|
149
|
+
return clause.length > 0 ? [clause] : []
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A catalog whose own builder already returns a clause LIST (held props). */
|
|
153
|
+
const viaListBuilder =
|
|
154
|
+
(build: (v: unknown, mode: PickerHintMode) => string[]) =>
|
|
155
|
+
(ids: readonly string[], mode: PickerHintMode): string[] =>
|
|
156
|
+
build(ids.length === 1 ? ids[0] : [...ids], mode).filter((s) => s.length > 0)
|
|
157
|
+
|
|
158
|
+
/** A catalog whose own builder returns ONE blended clause (materials). */
|
|
159
|
+
const viaStringBuilder =
|
|
160
|
+
(build: (v: unknown, mode: PickerHintMode) => string) =>
|
|
161
|
+
(ids: readonly string[], mode: PickerHintMode): string[] => {
|
|
162
|
+
const s = build(ids.length === 1 ? ids[0] : [...ids], mode)
|
|
163
|
+
return s.length > 0 ? [s] : []
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Independent per-id emission — the same doctrine as the direction registry's
|
|
168
|
+
* `perId`. Only ever receives NON-EMPTY ids (the normalizer drops empties).
|
|
169
|
+
*/
|
|
170
|
+
const perId =
|
|
171
|
+
(full: (id: string) => string, compact: (id: string) => string) =>
|
|
172
|
+
(ids: readonly string[], mode: PickerHintMode): string[] => {
|
|
173
|
+
const out: string[] = []
|
|
174
|
+
for (const id of ids) {
|
|
175
|
+
const frag = mode === "compact" ? compact(id) : full(id)
|
|
176
|
+
if (frag.length > 0) out.push(frag)
|
|
177
|
+
}
|
|
178
|
+
return out
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* THE TABLE. Declaration order IS fold order (`SUBJECT_FOLD_KEYS`), pinned by
|
|
183
|
+
* `__tests__/subject-registry.test.ts`.
|
|
184
|
+
*
|
|
185
|
+
* Person leads (who), then how they are styled, then what they hold, what it is
|
|
186
|
+
* made of, and finally the animal in frame. Both group rows run over the SAME
|
|
187
|
+
* normalized bag, in this order, which is what keeps the styling builder's
|
|
188
|
+
* `lipState` dedupe alive.
|
|
189
|
+
*
|
|
190
|
+
* SECOND MEANING OF POSITION (same as `DIRECTION_FIELDS`): both cap-aware
|
|
191
|
+
* assemblers fold this table AHEAD of the direction table into ONE sheddable
|
|
192
|
+
* list and shed it TAIL-FIRST through `hint-shedding.ts`, so a row's position
|
|
193
|
+
* here is also its survival order under a provider's prompt cap — subject
|
|
194
|
+
* clauses outlive every direction clause, and within this block `animal` leaves
|
|
195
|
+
* before `person`. A compatibility order, not a ranking; anything needing a
|
|
196
|
+
* real ranking adds an explicit priority column rather than reordering rows.
|
|
197
|
+
*/
|
|
198
|
+
export const SUBJECT_FIELDS = [
|
|
199
|
+
{ key: "person", kind: "group", surface: "both", render: viaFragmentBuilder(buildPersonHints) },
|
|
200
|
+
{ key: "styling", kind: "group", surface: "both", render: viaFragmentBuilder(buildStylingHints) },
|
|
201
|
+
{ key: "heldProp", kind: "ids", surface: "both", maxPicks: 2, render: viaListBuilder(buildHeldPropHints) },
|
|
202
|
+
{ key: "material", kind: "ids", surface: "both", maxPicks: 2, render: viaStringBuilder(buildMaterialHints) },
|
|
203
|
+
{ key: "animal", kind: "ids", surface: "both", maxPicks: 1, render: perId(getAnimalPromptHint, getAnimalTerm) },
|
|
204
|
+
] as const satisfies ReadonlyArray<SubjectFieldSpec>
|
|
205
|
+
|
|
206
|
+
export type SubjectFieldRow = (typeof SUBJECT_FIELDS)[number]
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Table order — THE canonical fold order, over ROWS (not wire keys). Exported
|
|
210
|
+
* so a client's "will inject into prompt" preview folds in the exact order the
|
|
211
|
+
* server does, the way `DIRECTION_KEYS` does for direction.
|
|
212
|
+
*/
|
|
213
|
+
export const SUBJECT_FOLD_KEYS: ReadonlyArray<string> = SUBJECT_FIELDS.map((f) => f.key)
|
|
214
|
+
|
|
215
|
+
/** The prop rows — the wire keys a fold row owns one-to-one. */
|
|
216
|
+
const IDS_ROWS: ReadonlyArray<SubjectIdsFieldSpec> = (
|
|
217
|
+
SUBJECT_FIELDS as ReadonlyArray<SubjectFieldSpec>
|
|
218
|
+
).filter((f): f is SubjectIdsFieldSpec => f.kind === "ids")
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The one person field that is a NUMBER, not ids: the literal age in years,
|
|
222
|
+
* consulted only when `age === "age-custom"` (`buildAgeFragment`). It has no
|
|
223
|
+
* styling twin, so it rides the flat bag without collision.
|
|
224
|
+
*/
|
|
225
|
+
export const SUBJECT_CUSTOM_AGE_KEY = "customAge"
|
|
226
|
+
|
|
227
|
+
/** `customAge` bounds, mirroring `buildAgeFragment`'s own clamp. */
|
|
228
|
+
const CUSTOM_AGE_MIN = 0
|
|
229
|
+
const CUSTOM_AGE_MAX = 120
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* THE WIRE KEY SET, derived — every person field, `customAge`, every styling
|
|
233
|
+
* field, then the prop rows, in fold order. Never hand-listed: a dimension
|
|
234
|
+
* added to either catalog joins the channel by construction.
|
|
235
|
+
*
|
|
236
|
+
* BASE ONLY, deliberately: a deployment-registered person pack adds dimensions
|
|
237
|
+
* at RUNTIME, so the pack-aware set is computed per call inside
|
|
238
|
+
* `normalizeSubjectFields` (and inside `readSubjectFields`). This constant is
|
|
239
|
+
* what a client enumerates to build its projection; a pack dimension it cannot
|
|
240
|
+
* know about still rides the wire and still folds.
|
|
241
|
+
*
|
|
242
|
+
* ONE CONSEQUENCE, noted rather than fixed: the person builder's legacy `lips`
|
|
243
|
+
* fallback field is not a dimension and so is not here, making it unreachable
|
|
244
|
+
* through this channel. Intended — the wire speaks the current vocabulary; the
|
|
245
|
+
* fallback stays for node blobs written before the split.
|
|
246
|
+
*/
|
|
247
|
+
export const SUBJECT_KEYS: ReadonlyArray<string> = [
|
|
248
|
+
...PERSON_DIMENSION_ORDER.map((d) => PERSON_FIELD_BY_DIMENSION[d]),
|
|
249
|
+
SUBJECT_CUSTOM_AGE_KEY,
|
|
250
|
+
...STYLING_DIMENSION_ORDER.map((d) => STYLING_FIELD_BY_DIMENSION[d]),
|
|
251
|
+
...IDS_ROWS.map((f) => f.key),
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* `SUBJECT_KEYS` plus whatever a deployment-registered person pack added — the
|
|
256
|
+
* set the DOORS accept (`readSubjectFields`, and any route-side normalize).
|
|
257
|
+
*
|
|
258
|
+
* Rebuilt per call because pack registration happens at boot, after this module
|
|
259
|
+
* is imported. With no packs registered it is `SUBJECT_KEYS` exactly, pinned by
|
|
260
|
+
* a test.
|
|
261
|
+
*/
|
|
262
|
+
export function getRegisteredSubjectKeys(): ReadonlyArray<string> {
|
|
263
|
+
const fieldByDimension = getRegisteredPersonFieldByDimension()
|
|
264
|
+
const personFields: string[] = []
|
|
265
|
+
for (const dimension of getRegisteredPersonDimensionOrder()) {
|
|
266
|
+
const field = fieldByDimension[dimension]
|
|
267
|
+
if (field) personFields.push(field)
|
|
268
|
+
}
|
|
269
|
+
return [
|
|
270
|
+
...personFields,
|
|
271
|
+
SUBJECT_CUSTOM_AGE_KEY,
|
|
272
|
+
...STYLING_DIMENSION_ORDER.map((d) => STYLING_FIELD_BY_DIMENSION[d]),
|
|
273
|
+
...IDS_ROWS.map((f) => f.key),
|
|
274
|
+
]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Verbosity for a whole subject fold. No family split — subject is one family. */
|
|
278
|
+
export type SubjectHintMode = PickerHintMode
|
|
279
|
+
|
|
280
|
+
/** Image policy: the full mechanism clause for every dimension. */
|
|
281
|
+
export const SUBJECT_IMAGE_HINT_MODE_DEFAULT: SubjectHintMode = "full"
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Video policy: the compact professional term.
|
|
285
|
+
*
|
|
286
|
+
* A fully specified person at `"full"` is ~30 paragraph clauses, and on the
|
|
287
|
+
* video stage the start frame already carries the subject's identity — the
|
|
288
|
+
* clip's prompt needs the subject NAMED, not re-described. (The image stage,
|
|
289
|
+
* which is where the subject is actually being built, keeps `"full"`.)
|
|
290
|
+
*/
|
|
291
|
+
export const SUBJECT_VIDEO_HINT_MODE_DEFAULT: SubjectHintMode = "compact"
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Wire tolerance ceiling for an array-valued subject key, and the ceiling for
|
|
295
|
+
* one id's length. DEFINED AS the direction constants rather than re-typed, so
|
|
296
|
+
* ONE literal governs both channels and both doors (the route schema and the
|
|
297
|
+
* persisted-node reader) — the wire and the canvas cannot start disagreeing
|
|
298
|
+
* about which strings are ids at all.
|
|
299
|
+
*/
|
|
300
|
+
export const SUBJECT_ARRAY_CEILING = DIRECTION_ARRAY_CEILING
|
|
301
|
+
export const SUBJECT_ID_MAX_CHARS = DIRECTION_ID_MAX_CHARS
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Bound on the NUMBER of keys in a subject bag. `SUBJECT_KEYS` is 54 today;
|
|
305
|
+
* this leaves generous headroom for deployment-registered pack dimensions while
|
|
306
|
+
* still closing an otherwise unbounded record that lands verbatim in
|
|
307
|
+
* `jobs.input_data`. Unknown keys are inert (the renderer never reads them), so
|
|
308
|
+
* this is storage hygiene, not validation.
|
|
309
|
+
*/
|
|
310
|
+
export const MAX_SUBJECT_KEYS = 128
|
|
311
|
+
|
|
312
|
+
/** Bound on the LENGTH of one wire key. Field names are short identifiers. */
|
|
313
|
+
export const SUBJECT_KEY_MAX_CHARS = 64
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* `string | string[]` → deduped, non-empty, capped id list.
|
|
317
|
+
*
|
|
318
|
+
* Byte-for-byte the direction registry's `normalizeDirectionIds`, including the
|
|
319
|
+
* `ids.length >= maxPicks` bail — load-bearing, not an optimization: the dedupe
|
|
320
|
+
* is an `includes` scan, so without it the cost is quadratic in the CALLER's
|
|
321
|
+
* array length, and one caller reads untrusted persisted JSONB. Bailing is
|
|
322
|
+
* semantics-preserving: once `maxPicks` unique ids exist, no later entry can
|
|
323
|
+
* change the sliced result.
|
|
324
|
+
*/
|
|
325
|
+
function normalizeIds(value: unknown, maxPicks: number): string[] {
|
|
326
|
+
const ids: string[] = []
|
|
327
|
+
if (typeof value === "string") {
|
|
328
|
+
if (value) ids.push(value)
|
|
329
|
+
} else if (Array.isArray(value)) {
|
|
330
|
+
for (const v of value) {
|
|
331
|
+
if (ids.length >= maxPicks) break
|
|
332
|
+
if (typeof v === "string" && v && !ids.includes(v)) ids.push(v)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return ids.slice(0, maxPicks)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Pack-aware field → per-dimension pick limit, rebuilt per call.
|
|
340
|
+
*
|
|
341
|
+
* Person's dimension→field map is pack-composed at runtime
|
|
342
|
+
* (`getRegisteredPersonFieldByDimension`), so this cannot be a module constant;
|
|
343
|
+
* a pack dimension is single-select today (`getPersonDimensionLimit` knows only
|
|
344
|
+
* the base union and resolves a pack key to 1 — see `PersonPack.dimensions`).
|
|
345
|
+
* Styling has no pack seam and reads its static map.
|
|
346
|
+
*/
|
|
347
|
+
function limitByField(): Map<string, number> {
|
|
348
|
+
const out = new Map<string, number>()
|
|
349
|
+
const personFieldByDimension = getRegisteredPersonFieldByDimension()
|
|
350
|
+
for (const dimension of getRegisteredPersonDimensionOrder()) {
|
|
351
|
+
const field = personFieldByDimension[dimension]
|
|
352
|
+
if (field) out.set(field, getPersonDimensionLimit(dimension as PersonDimension))
|
|
353
|
+
}
|
|
354
|
+
for (const dimension of STYLING_DIMENSION_ORDER) {
|
|
355
|
+
out.set(
|
|
356
|
+
STYLING_FIELD_BY_DIMENSION[dimension],
|
|
357
|
+
getStylingDimensionLimit(dimension as StylingDimension),
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
for (const row of IDS_ROWS) out.set(row.key, row.maxPicks)
|
|
361
|
+
return out
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* THE NORMALIZER — a known-key, per-dimension-capped copy of a subject bag.
|
|
366
|
+
*
|
|
367
|
+
* WHY IT EXISTS (and why the fold is unusable without it): the subject builders
|
|
368
|
+
* do NOT cap. `collectPersonFragments` / `collectStylingFragments` /
|
|
369
|
+
* `emitIndependentFragments` emit EVERY id they are handed; `normalizePickIds`
|
|
370
|
+
* and `pickHeldPropIds` dedupe but never slice; only `buildMaterialHints` caps,
|
|
371
|
+
* and only structurally at 2. `MAX_SELECTED_BY_DIMENSION`'s own doc names its
|
|
372
|
+
* consumers as the picker UI, the analyzer schema and the Zod validator — NOT
|
|
373
|
+
* the builders. Straight off the wire, then, a bag is a clause amplifier: 8 ids
|
|
374
|
+
* (the array ceiling) on each of ~50 dimensions. This is where the per-dimension
|
|
375
|
+
* cap actually gets enforced.
|
|
376
|
+
*
|
|
377
|
+
* It also:
|
|
378
|
+
* - DROPS UNKNOWN KEYS, so `jobs.input_data` stays the platform's vocabulary
|
|
379
|
+
* (unknown IDS stay inert instead — every getter resolves a miss to `""`);
|
|
380
|
+
* - UNWRAPS a single-id array to a bare string. Load-bearing:
|
|
381
|
+
* `collectPersonFragments` reads a single-pick dimension with
|
|
382
|
+
* `typeof raw === "string"`, so a legitimate `["hair-base-long"]` from a
|
|
383
|
+
* client's array-shaped store would otherwise contribute NOTHING;
|
|
384
|
+
* - clamps `customAge` to a whole `0..120`, mirroring `buildAgeFragment`.
|
|
385
|
+
*
|
|
386
|
+
* Copy-on-write: the caller's object is never mutated. Idempotent, so calling it
|
|
387
|
+
* at a door AND inside the renderer is free.
|
|
388
|
+
*/
|
|
389
|
+
export function normalizeSubjectFields(
|
|
390
|
+
subject: SubjectFields | undefined,
|
|
391
|
+
): SubjectFields | undefined {
|
|
392
|
+
if (!subject || typeof subject !== "object" || Array.isArray(subject)) return undefined
|
|
393
|
+
const src = subject as Record<string, unknown>
|
|
394
|
+
const limits = limitByField()
|
|
395
|
+
const out: Record<string, string | string[] | number> = {}
|
|
396
|
+
|
|
397
|
+
const rawAge = src[SUBJECT_CUSTOM_AGE_KEY]
|
|
398
|
+
if (typeof rawAge === "number" && Number.isFinite(rawAge)) {
|
|
399
|
+
out[SUBJECT_CUSTOM_AGE_KEY] = Math.max(
|
|
400
|
+
CUSTOM_AGE_MIN,
|
|
401
|
+
Math.min(CUSTOM_AGE_MAX, Math.round(rawAge)),
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
for (const [field, limit] of limits) {
|
|
406
|
+
const ids = normalizeIds(src[field], limit)
|
|
407
|
+
if (ids.length === 0) continue
|
|
408
|
+
out[field] = ids.length === 1 ? ids[0]! : ids
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The rows a given generation stage folds, in table order. */
|
|
415
|
+
export function subjectFieldsForSurface(
|
|
416
|
+
surface: "image" | "video",
|
|
417
|
+
): ReadonlyArray<SubjectFieldSpec> {
|
|
418
|
+
return SUBJECT_FIELDS.filter((f) => f.surface === "both" || f.surface === surface)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Every clause the `subject` channel injects, in canonical table order.
|
|
423
|
+
*
|
|
424
|
+
* NORMALIZES ONCE, at the top, and hands THE SAME bag to both group rows — that
|
|
425
|
+
* single shared bag is the mechanism behind the styling builder's cross-catalog
|
|
426
|
+
* `lipState` dedupe, and it must not depend on a door having normalized first
|
|
427
|
+
* (the normalizer is idempotent, so a door may do it too).
|
|
428
|
+
*
|
|
429
|
+
* Iterates the TABLE, never the caller's object: unknown wire keys contribute
|
|
430
|
+
* nothing, the order is platform-owned, and an off-surface row is inert.
|
|
431
|
+
* Unknown IDS are skipped too — every `get*PromptHint` returns `""` on a miss,
|
|
432
|
+
* so a retired or pack-only id costs no clause rather than a 400.
|
|
433
|
+
*
|
|
434
|
+
* DEDUPE by exact clause string, FIRST OCCURRENCE WINS (order-preserving),
|
|
435
|
+
* matching `renderDirectionHints`.
|
|
436
|
+
*
|
|
437
|
+
* Exported so a client's "will inject into prompt" preview renders the exact
|
|
438
|
+
* server output — called on the very object the client sends — instead of
|
|
439
|
+
* re-implementing the fold.
|
|
440
|
+
*/
|
|
441
|
+
export function renderSubjectHints(
|
|
442
|
+
subject: SubjectFields | undefined,
|
|
443
|
+
opts: { surface: "image" | "video"; mode?: SubjectHintMode },
|
|
444
|
+
): string[] {
|
|
445
|
+
const bag = normalizeSubjectFields(subject)
|
|
446
|
+
if (!bag) return []
|
|
447
|
+
const mode = opts.mode ?? "full"
|
|
448
|
+
const out: string[] = []
|
|
449
|
+
const seen = new Set<string>()
|
|
450
|
+
for (const spec of SUBJECT_FIELDS) {
|
|
451
|
+
if (spec.surface !== "both" && spec.surface !== opts.surface) continue
|
|
452
|
+
const hints =
|
|
453
|
+
spec.kind === "group"
|
|
454
|
+
? spec.render(bag, mode)
|
|
455
|
+
: spec.render(normalizeIds((bag as Record<string, unknown>)[spec.key], spec.maxPicks), mode)
|
|
456
|
+
for (const hint of hints) {
|
|
457
|
+
if (hint.length > 0 && !seen.has(hint)) {
|
|
458
|
+
seen.add(hint)
|
|
459
|
+
out.push(hint)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return out
|
|
464
|
+
}
|