@nodaro/prompts 1.14.0 → 1.16.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 +680 -251
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +406 -1
- package/dist/index.d.ts +406 -1
- package/dist/index.js +649 -253
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/adult-only-ratchet.test.ts +110 -0
- package/src/__tests__/age-floor.test.ts +347 -0
- package/src/__tests__/catalog-id-guard.test.ts +146 -0
- package/src/__tests__/catalog-overlay.test.ts +163 -0
- package/src/__tests__/doctrine-roster-completeness.test.ts +1 -0
- package/src/__tests__/dod-replace-pack-acceptance.test.ts +17 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +315 -37
- package/src/__tests__/minor-age-floor.test.ts +136 -0
- package/src/__tests__/person-body-axes.test.ts +2 -2
- package/src/__tests__/person-exposure-hints.test.ts +59 -4
- package/src/__tests__/person-pack-adult-only.test.ts +45 -0
- package/src/__tests__/picker-analyzer-registry.test.ts +14 -0
- package/src/__tests__/retired-hint-strings.test.ts +68 -0
- package/src/__tests__/subject-registry.test.ts +1 -1
- package/src/__tests__/w1b-rephrase.test.ts +119 -0
- package/src/action-fx.ts +2 -1
- package/src/aesthetic.ts +2 -1
- package/src/age-floor.ts +359 -0
- package/src/age-signal.ts +36 -0
- package/src/atmosphere.ts +2 -1
- package/src/backdrop.ts +2 -1
- package/src/camera-format.ts +2 -1
- package/src/camera-motions.ts +2 -1
- package/src/catalog-id-guard.ts +189 -0
- package/src/catalog-overlay.ts +148 -0
- package/src/catalog-packs.ts +27 -2
- package/src/character-fx.ts +2 -1
- package/src/color-look.ts +2 -1
- package/src/composition-effects.ts +2 -1
- package/src/era.ts +2 -1
- package/src/exposure-settings.ts +2 -1
- package/src/framing.ts +2 -1
- package/src/held-prop.ts +2 -1
- package/src/index.ts +4 -0
- package/src/instrumentation.ts +5 -4
- package/src/lens.ts +2 -1
- package/src/lighting.ts +2 -1
- package/src/loop-subject.ts +3 -1
- package/src/materials.ts +2 -1
- package/src/mood.ts +16 -5
- package/src/music-genre.ts +5 -4
- package/src/music-mood.ts +4 -3
- package/src/parameter-prompt-hint.ts +13 -57
- package/src/person-packs.ts +14 -2
- package/src/person.ts +76 -42
- package/src/photo-genre.ts +14 -3
- package/src/photographer.ts +2 -1
- package/src/picker-analyzer-registry.ts +9 -0
- package/src/picker-catalogs.ts +11 -0
- package/src/pose.ts +17 -6
- package/src/post-process-effects.ts +2 -1
- package/src/prompt-builder.ts +3 -3
- package/src/render-quality.ts +2 -1
- package/src/setting.ts +2 -1
- package/src/shared-catalog-overlay.ts +83 -0
- package/src/style.ts +16 -1
- package/src/styling.ts +93 -29
- package/src/subject-registry.ts +2 -2
- package/src/temporal.ts +2 -1
- package/src/transitions.ts +2 -1
- package/src/voice-character.ts +6 -5
- package/src/voice-delivery.ts +4 -3
package/src/age-floor.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The minor-age floor — Layer 1 of spec 2026-09-01-app-reports-triage-design.md
|
|
3
|
+
* §3.3 (W1-a). Content-free: this module decides WHICH catalog entries a minor
|
|
4
|
+
* subject may not carry; the clause a deployment appends lives in its
|
|
5
|
+
* registered PromptPolicy (backend/src/lib/prompt-policies/), never here.
|
|
6
|
+
*
|
|
7
|
+
* `ADULT_ONLY_FLAG`, `ADULT_AGE_IDS`, `MINOR_IMPLYING_TYPE_IDS` and
|
|
8
|
+
* `isMinorAge` live in the import-free leaf `age-signal.ts` and are
|
|
9
|
+
* re-exported here unchanged — this module's own catalog-funnel imports
|
|
10
|
+
* (`person-packs.js`, `picker-catalogs.js`) are exactly what `person.ts` /
|
|
11
|
+
* `styling.ts` must NOT reach through, on pain of reopening the
|
|
12
|
+
* person → age-floor → picker-catalogs load-time cycle (see `age-signal.ts`'s
|
|
13
|
+
* header). `person.ts` / `styling.ts` import `isMinorAge` from
|
|
14
|
+
* `age-signal.ts` directly instead of from here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getRegisteredPeople } from "./person-packs.js"
|
|
18
|
+
import { getRegisteredPickerCatalogs, type PickerCatalog, type PickerOption } from "./picker-catalogs.js"
|
|
19
|
+
import { isMinorAge, ADULT_AGE_IDS, MINOR_IMPLYING_TYPE_IDS } from "./age-signal.js"
|
|
20
|
+
|
|
21
|
+
export { ADULT_ONLY_FLAG, ADULT_AGE_IDS, MINOR_IMPLYING_TYPE_IDS, isMinorAge } from "./age-signal.js"
|
|
22
|
+
|
|
23
|
+
/** Shape shared by every catalog entry the floor reads. */
|
|
24
|
+
export interface AdultOnlyEntry {
|
|
25
|
+
readonly id: string
|
|
26
|
+
readonly promptHint: string
|
|
27
|
+
readonly term?: string
|
|
28
|
+
readonly adultOnly?: true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The picker catalogs (by `catalogId`) this floor sweeps beside person.
|
|
32
|
+
* Exported because it is the SINGLE source of truth for two things that used
|
|
33
|
+
* to be maintained by hand and drifted: which catalogs `getAdultOnlyEntries`
|
|
34
|
+
* sweeps for the flag, and which analyzer keys `FLOORED_PICKER_KEYS` strips
|
|
35
|
+
* flagged ids out of (a `photo-genre` swept for the flag but absent from the
|
|
36
|
+
* strip list let `glamour-portrait` survive on a minor). */
|
|
37
|
+
export const ADULT_SWEPT_CATALOG_IDS = ["styling", "mood", "pose", "photo-genre"] as const
|
|
38
|
+
|
|
39
|
+
/** Membership form of the list above (the sweep does one lookup per catalog). */
|
|
40
|
+
const ADULT_SWEPT_CATALOG_ID_SET: ReadonlySet<string> = new Set(ADULT_SWEPT_CATALOG_IDS)
|
|
41
|
+
|
|
42
|
+
/** Flatten a catalog's options regardless of shape: single-dim catalogs carry
|
|
43
|
+
* `options` directly, multi-dim ones carry `dimensions[].options` (mirrors
|
|
44
|
+
* `applyDeny` in catalog-packs.ts, the other consumer that has to handle
|
|
45
|
+
* both shapes). */
|
|
46
|
+
function optionsOf(catalog: PickerCatalog): ReadonlyArray<PickerOption> {
|
|
47
|
+
if (catalog.kind === "single") return catalog.options ?? []
|
|
48
|
+
return (catalog.dimensions ?? []).flatMap((d) => d.options)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every entry, across the swept catalogs, that carries the flag. Reads every
|
|
52
|
+
* catalog through the pack-composed funnel — `getRegisteredPeople()` for
|
|
53
|
+
* person, `getRegisteredPickerCatalogs()` for styling/mood/pose/photo-genre —
|
|
54
|
+
* so a pack-added or pack-replaced entry is included. */
|
|
55
|
+
export function getAdultOnlyEntries(): ReadonlyArray<AdultOnlyEntry> {
|
|
56
|
+
const catalogEntries = getRegisteredPickerCatalogs()
|
|
57
|
+
.filter((c) => ADULT_SWEPT_CATALOG_ID_SET.has(c.catalogId))
|
|
58
|
+
.flatMap(optionsOf)
|
|
59
|
+
const all: ReadonlyArray<AdultOnlyEntry> = [...getRegisteredPeople(), ...catalogEntries]
|
|
60
|
+
return all.filter((e) => e.adultOnly === true)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The set of flagged ids (fast membership for the collectors). */
|
|
64
|
+
export function getAdultOnlyIds(): ReadonlySet<string> {
|
|
65
|
+
return new Set(getAdultOnlyEntries().map((e) => e.id))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The pre-rework `promptHint` of every `adultOnly` entry the spec
|
|
70
|
+
* (2026-09-01-app-reports-triage-design §3.3) listed for rewording — including
|
|
71
|
+
* `eye-state-half-lidded` and `feature-collarbone-visible`, which the W1-b
|
|
72
|
+
* harness initially left byte-identical (0/10 and 0/22 render rate under
|
|
73
|
+
* either wording) and a 2026-09-03 replay then found renderable wordings for
|
|
74
|
+
* — plus the hard-coded midriff+navel fold clause. These strings are permanently part of the strip
|
|
75
|
+
* set: a consumer that has not bumped `@nodaro/prompts` still emits them, and
|
|
76
|
+
* the client-assembled `seedPrompt` path is exactly how the 2026-07-30
|
|
77
|
+
* minor-age prompts reached a provider. Retiring can only ever cause an EXTRA
|
|
78
|
+
* strip, and only for a subject `isMinorAge` has already judged a minor — so
|
|
79
|
+
* the list only grows, never shrinks.
|
|
80
|
+
*
|
|
81
|
+
* `promptHint` ONLY, for the same reason `getAdultOnlyHintStrings` gives:
|
|
82
|
+
* `term` is short display vocabulary and the composed-catalog projection
|
|
83
|
+
* back-fills a derived `term` for every option, so terms collide with benign
|
|
84
|
+
* everyday text. Assembled prompts and client seedPrompts are built from
|
|
85
|
+
* hints, never from terms. The three W1-b entries that are NOT flagged —
|
|
86
|
+
* `texture-dewy`, `texture-baby-soft`, `eye-state-staring-camera` (spec
|
|
87
|
+
* §3.3's "deliberately not flagged" list) — are absent: their old wording
|
|
88
|
+
* never belonged to the floor.
|
|
89
|
+
*
|
|
90
|
+
* 15 flagged hints + the fold literal = 16.
|
|
91
|
+
*/
|
|
92
|
+
export const RETIRED_ADULT_ONLY_HINT_STRINGS: ReadonlyArray<string> = [
|
|
93
|
+
// bust-very-full
|
|
94
|
+
"very full bust",
|
|
95
|
+
// silhouette-hourglass
|
|
96
|
+
"hourglass silhouette",
|
|
97
|
+
// waist-defined
|
|
98
|
+
"defined waist",
|
|
99
|
+
// lip-state-glossy
|
|
100
|
+
"with high-shine glossy wet-look lips",
|
|
101
|
+
// lip-state-parted
|
|
102
|
+
"with lips slightly parted, taking a soft breath",
|
|
103
|
+
// lip-state-bitten
|
|
104
|
+
"playfully biting the lower lip",
|
|
105
|
+
// eye-state-half-lidded
|
|
106
|
+
"with heavy half-lidded sleepy eyes",
|
|
107
|
+
// texture-glistening
|
|
108
|
+
"with glistening skin, sweat or oil catching the light",
|
|
109
|
+
// texture-shower-fresh-wet
|
|
110
|
+
"with just-out-of-the-shower wet skin, water beading on the surface and rolling in slow droplets down the curves of the body",
|
|
111
|
+
// feature-bare-shoulders
|
|
112
|
+
"with bare shoulders exposed, the line of the collarbone and shoulder muscles uncovered",
|
|
113
|
+
// feature-collarbone-visible
|
|
114
|
+
"with a prominent collarbone clearly defined and catching the light",
|
|
115
|
+
// feature-midriff-visible
|
|
116
|
+
"wearing a cropped style with the midriff visible",
|
|
117
|
+
// state-fitted — the 2026-07-30 incident clause
|
|
118
|
+
"the clothing fitted and form-conscious, hugging the contours of the body",
|
|
119
|
+
// state-wet
|
|
120
|
+
"the clothing soaked and wet, the fabric clinging to the body and dripping water",
|
|
121
|
+
// pose `biting-lip` (the lip-state-bitten twin)
|
|
122
|
+
"biting the lower lip with a subtle playful expression",
|
|
123
|
+
// the hard-coded midriff+navel fold in emitIndependentFragments — not any
|
|
124
|
+
// entry's hint, so retiring it ADDS a needle for a clause that really is
|
|
125
|
+
// emitted verbatim.
|
|
126
|
+
"wearing a cropped style, midriff and navel visible",
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
/** Every full prompt-hint string a flagged entry can inject, lower-cased,
|
|
130
|
+
* longest first — the backend policy strips text that contains any of them
|
|
131
|
+
* (Layer 2), which is how flagged wording arriving inside free text (a
|
|
132
|
+
* client-assembled seedPrompt) is caught. Seeded with
|
|
133
|
+
* `RETIRED_ADULT_ONLY_HINT_STRINGS` so a catalog rewording can never narrow
|
|
134
|
+
* the strip set for a consumer still shipping the pre-rephrase wording.
|
|
135
|
+
*
|
|
136
|
+
* `promptHint` ONLY, deliberately — `term` is analyzer/UI vocabulary
|
|
137
|
+
* (compact-mode display), and `picker-catalogs.ts`'s composed-catalog
|
|
138
|
+
* projection back-fills a `term` for EVERY option via `deriveTerm(label)`
|
|
139
|
+
* when no explicit term was authored (e.g. "lounging", "cropped top",
|
|
140
|
+
* "school uniform", "lying down"). Those short, generic derived terms are
|
|
141
|
+
* exactly the kind of everyday word that collides with unrelated, benign
|
|
142
|
+
* text and over-strips it. Assembled prompts and client seedPrompts are
|
|
143
|
+
* built from HINTS, never from terms, so hints are the only strings that
|
|
144
|
+
* can actually arrive verbatim in free text — terms don't need to be (and
|
|
145
|
+
* must not be) swept here. */
|
|
146
|
+
export function getAdultOnlyHintStrings(): ReadonlyArray<string> {
|
|
147
|
+
const out = new Set<string>(RETIRED_ADULT_ONLY_HINT_STRINGS)
|
|
148
|
+
for (const e of getAdultOnlyEntries()) {
|
|
149
|
+
if (e.promptHint) out.add(e.promptHint.toLowerCase())
|
|
150
|
+
}
|
|
151
|
+
return [...out].filter((s) => s.length >= 8).sort((a, b) => b.length - a.length)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every full prompt-hint string that DESCRIBES A MINOR — the mirror of
|
|
156
|
+
* `getAdultOnlyHintStrings`, and the needle list for the TEXT signal below.
|
|
157
|
+
*
|
|
158
|
+
* Why a text signal exists at all: `isMinorAge` reads the structured picker
|
|
159
|
+
* value, and the P0 arrival path does not have one. A thin client can create a
|
|
160
|
+
* character row carrying only `{nodeId, name, projectId}` and send the picker
|
|
161
|
+
* selection as an already-assembled `seedPrompt`, so `row.person === null`
|
|
162
|
+
* while the prompt itself says "a young child around 5 years old". That client
|
|
163
|
+
* assembles the text FROM THESE HINTS, which is exactly why the catalog is the
|
|
164
|
+
* drift-proof needle list: a new minor age entry is swept the day it is added,
|
|
165
|
+
* with no second list to remember.
|
|
166
|
+
*
|
|
167
|
+
* Selection is DERIVED, never hand-listed: an `age` entry is a minor unless its
|
|
168
|
+
* id is in the `ADULT_AGE_IDS` allow-list (so a new age id is inside the floor
|
|
169
|
+
* by default — same ratchet as `isMinorAge`), plus every `type` entry named by
|
|
170
|
+
* `MINOR_IMPLYING_TYPE_IDS`. Read through the pack-composed funnel
|
|
171
|
+
* (`getRegisteredPeople()`), so a deployment pack's entries are swept too.
|
|
172
|
+
*
|
|
173
|
+
* `promptHint` ONLY — never `term` and never `label`, for the same reason
|
|
174
|
+
* `getAdultOnlyHintStrings` gives: `term` is short display vocabulary
|
|
175
|
+
* ("in their teens" as a compact chip) and the composed-catalog projection
|
|
176
|
+
* back-fills a derived `term` for every option, so terms collide with benign
|
|
177
|
+
* text. The `>= 8` filter drops `age-custom`'s empty hint (`""`), which would
|
|
178
|
+
* otherwise match every string ever written.
|
|
179
|
+
*/
|
|
180
|
+
let cachedMinorHints: ReadonlyArray<string> | null = null
|
|
181
|
+
let cachedMinorHintSourceCount = -1
|
|
182
|
+
export function getMinorAgeHintStrings(): ReadonlyArray<string> {
|
|
183
|
+
const people = getRegisteredPeople()
|
|
184
|
+
if (cachedMinorHints && cachedMinorHintSourceCount === people.length) return cachedMinorHints
|
|
185
|
+
const out = new Set<string>()
|
|
186
|
+
for (const e of people) {
|
|
187
|
+
const minorAge = e.dimension === "age" && !ADULT_AGE_IDS.has(e.id)
|
|
188
|
+
const minorType = e.dimension === "type" && MINOR_IMPLYING_TYPE_IDS.has(e.id)
|
|
189
|
+
if (!minorAge && !minorType) continue
|
|
190
|
+
if (e.promptHint) out.add(e.promptHint.toLowerCase())
|
|
191
|
+
}
|
|
192
|
+
cachedMinorHintSourceCount = people.length
|
|
193
|
+
cachedMinorHints = [...out].filter((s) => s.length >= 8).sort((a, b) => b.length - a.length)
|
|
194
|
+
return cachedMinorHints
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function escapeReSource(s: string): string {
|
|
198
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** One needle's regex source: the phrase's tokens — split on whitespace AND
|
|
202
|
+
* hyphens — escaped and rejoined by `[\s-]+`, so it matches however the text
|
|
203
|
+
* that carries it happened to be spaced or hyphenated. */
|
|
204
|
+
function needleTokenSource(needle: string): string {
|
|
205
|
+
return needle
|
|
206
|
+
.trim()
|
|
207
|
+
.split(/[\s-]+/)
|
|
208
|
+
.filter((t) => t.length > 0)
|
|
209
|
+
.map(escapeReSource)
|
|
210
|
+
.join("[\\s-]+")
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The word-bounded alternation SOURCE for a needle list — the one place both
|
|
215
|
+
* layers of the floor build their matcher, so they can never disagree about
|
|
216
|
+
* what "the same phrase" means.
|
|
217
|
+
*
|
|
218
|
+
* Two properties, both load-bearing:
|
|
219
|
+
* - **Separator tolerance.** A catalog hint is authored with single spaces
|
|
220
|
+
* ("very full bust"), but the free text that carries it is written by a
|
|
221
|
+
* human or an LLM and arrives as "very-full bust" or "very full bust".
|
|
222
|
+
* A literal alternation misses those, and a miss is a flagged phrase
|
|
223
|
+
* reaching the provider on a minor's prompt. Each needle's tokens are
|
|
224
|
+
* joined by `[\s-]+`, which requires AT LEAST one separator — so
|
|
225
|
+
* "very fullbust" (no separator at all) is still not a match, and the
|
|
226
|
+
* phrase can't silently widen into a substring rule.
|
|
227
|
+
* - **Word boundaries.** `(?<![\w-])` / `(?![\w-])` keep a needle from
|
|
228
|
+
* gluing onto an adjacent word's characters ("mesh tops" must survive a
|
|
229
|
+
* "mesh top" needle).
|
|
230
|
+
*
|
|
231
|
+
* Ordering is the CALLER's: pass needles longest-first so the alternation
|
|
232
|
+
* consumes the longest match at a given position instead of leaving debris.
|
|
233
|
+
* Returns `null` when there is nothing to match (an empty list would otherwise
|
|
234
|
+
* compile to `(?:)`, which matches everywhere). Flags are the caller's too —
|
|
235
|
+
* `"i"` for a `.test()` instance, `"gi"` for one used with `String.replace`.
|
|
236
|
+
*/
|
|
237
|
+
export function buildNeedleAlternationSource(needles: ReadonlyArray<string>): string | null {
|
|
238
|
+
const alts = needles.map(needleTokenSource).filter((s) => s.length > 0)
|
|
239
|
+
if (alts.length === 0) return null
|
|
240
|
+
return `(?<![\\w-])(?:${alts.join("|")})(?![\\w-])`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Needle alternation over `getMinorAgeHintStrings()`, word-bounded on both
|
|
244
|
+
* sides so a hint can only match a whole phrase. Deliberately NOT global:
|
|
245
|
+
* this instance is only ever `.test()`ed, and a `/g` regex would carry
|
|
246
|
+
* `lastIndex` across calls. Cached by needle count (packs are registered
|
|
247
|
+
* before the first prompt is assembled). */
|
|
248
|
+
let cachedMinorNeedleRe: RegExp | null = null
|
|
249
|
+
let cachedMinorNeedleCount = -1
|
|
250
|
+
function minorNeedleRegex(): RegExp | null {
|
|
251
|
+
const needles = getMinorAgeHintStrings()
|
|
252
|
+
if (cachedMinorNeedleRe && cachedMinorNeedleCount === needles.length) return cachedMinorNeedleRe
|
|
253
|
+
cachedMinorNeedleCount = needles.length
|
|
254
|
+
const src = buildNeedleAlternationSource(needles)
|
|
255
|
+
cachedMinorNeedleRe = src === null ? null : new RegExp(src, "i")
|
|
256
|
+
return cachedMinorNeedleRe
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* NUMBER-FIRST age shapes: the ones `buildAgeFragment` (person.ts) emits for a
|
|
261
|
+
* CUSTOM age ("N years old", "N year old", "N-year-old") and the catalog's own
|
|
262
|
+
* "2-3 years old" range (the FIRST number decides), plus the colloquial tails a
|
|
263
|
+
* human or an LLM writes instead — "12yo", "12 yo", "12 y.o.", "12 y/o",
|
|
264
|
+
* "12 yr old", "12-yr-old", "12yrs old", "12 years of age". The cut is `< 20`,
|
|
265
|
+
* the same boundary `isMinorAge` uses for `customAge` and the same one
|
|
266
|
+
* `buildAgeFragment` switches to "in their teens" at.
|
|
267
|
+
*
|
|
268
|
+
* Both boundaries are load-bearing. The LEADING one stops a match inside a
|
|
269
|
+
* larger token; the TRAILING one is what makes "a 5 year older sibling" a
|
|
270
|
+
* non-hit ("old" may not glue onto "older") and what stops "yo" from firing
|
|
271
|
+
* inside "12 young" or "12 yoga". Used via `matchAll`, which clones the regex
|
|
272
|
+
* rather than advancing this instance's `lastIndex`.
|
|
273
|
+
*/
|
|
274
|
+
const NUMERIC_AGE_RE =
|
|
275
|
+
/(?<![\w-])(\d{1,3})(?:\s*-\s*\d{1,3})?\s*-?\s*(?:years?\s*-?\s*old|yrs?\s*-?\s*old|y(?:\.\s*o\.?|\/o|o)|years?\s+of\s+age)(?![\w-])/gi
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* AGE-FIRST shapes, where the number trails the word instead of leading it:
|
|
279
|
+
* "age 12", "aged 12", "at the age of 12". The leading boundary is what keeps
|
|
280
|
+
* "image 12" / "page 12" out (the `age` there is glued to a preceding word
|
|
281
|
+
* character), and the trailing one keeps "aged 12th" out.
|
|
282
|
+
*/
|
|
283
|
+
const PREFIXED_AGE_RE = /(?<![\w-])aged?\s+(?:of\s+)?(\d{1,3})(?![\w-])/gi
|
|
284
|
+
|
|
285
|
+
/** Every rule that reads a NUMBER out of the text, each capturing the age in
|
|
286
|
+
* group 1. Iterated together below so a new shape is one array entry. */
|
|
287
|
+
const AGE_NUMBER_RULES: ReadonlyArray<RegExp> = [NUMERIC_AGE_RE, PREFIXED_AGE_RE]
|
|
288
|
+
|
|
289
|
+
/** The one NON-numeric shape `buildAgeFragment` emits below 20 (`${n} years
|
|
290
|
+
* old, in their teens`). Kept as its own rule rather than as a catalog term.
|
|
291
|
+
* Separator-tolerant on the same terms as the needle alternation, so
|
|
292
|
+
* "in their teens" and "in-their-teens" are the same phrase. */
|
|
293
|
+
const IN_THEIR_TEENS_RE = /(?<![\w-])in[\s-]+their[\s-]+teens(?![\w-])/i
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* True when free text describes a MINOR subject. Three rules, in order:
|
|
297
|
+
* 1. any minor-age / minor-implying-type prompt hint, word-bounded;
|
|
298
|
+
* 2. a numeric age below 20 in any shape `buildAgeFragment` emits;
|
|
299
|
+
* 3. the literal "in their teens".
|
|
300
|
+
*
|
|
301
|
+
* Deliberately NOT a bare-word check: "child", "teen", "kid" alone must never
|
|
302
|
+
* fire, or an adult prompt that merely MENTIONS a child ("a mother holding her
|
|
303
|
+
* child") would be floored — the spec's "adults are byte-identical" is a hard
|
|
304
|
+
* requirement, so every rule here is a full phrase or a bounded number.
|
|
305
|
+
*
|
|
306
|
+
* Pairs with `isMinorAge`, never replaces it: the structured picker value is
|
|
307
|
+
* the primary signal; this catches the same subject when only the assembled
|
|
308
|
+
* text survives.
|
|
309
|
+
*/
|
|
310
|
+
export function containsMinorAgeHint(text: string | null | undefined): boolean {
|
|
311
|
+
if (typeof text !== "string" || text.trim().length === 0) return false
|
|
312
|
+
const re = minorNeedleRegex()
|
|
313
|
+
if (re && re.test(text)) return true
|
|
314
|
+
for (const rule of AGE_NUMBER_RULES) {
|
|
315
|
+
for (const m of text.matchAll(rule)) {
|
|
316
|
+
const n = Number(m[1])
|
|
317
|
+
if (Number.isFinite(n) && n < 20) return true
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return IN_THEIR_TEENS_RE.test(text)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The analyzer/import value keys the floor strips flagged ids out of:
|
|
324
|
+
* `person` plus EVERY catalog the flag sweep reads. DERIVED, never
|
|
325
|
+
* hand-listed — the hand-written version omitted `photo-genre` while the
|
|
326
|
+
* sweep included it, so a minor kept `glamour-portrait`. The analyzer JSON is
|
|
327
|
+
* keyed by picker type id (`describe-to-picker.ts`), which is the same string
|
|
328
|
+
* as the catalog id, so this list IS the set of keys to visit. */
|
|
329
|
+
export const FLOORED_PICKER_KEYS = ["person", ...ADULT_SWEPT_CATALOG_IDS] as const
|
|
330
|
+
|
|
331
|
+
function stripIds(obj: Record<string, unknown>, drop: ReadonlySet<string>): Record<string, unknown> {
|
|
332
|
+
const out: Record<string, unknown> = {}
|
|
333
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
334
|
+
if (typeof v === "string") {
|
|
335
|
+
if (!drop.has(v)) out[k] = v
|
|
336
|
+
} else if (Array.isArray(v)) {
|
|
337
|
+
const kept = v.filter((x) => !(typeof x === "string" && drop.has(x)))
|
|
338
|
+
if (kept.length > 0) out[k] = kept
|
|
339
|
+
} else {
|
|
340
|
+
out[k] = v
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return out
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Analyzer / import post-filter: when the person value describes a minor,
|
|
347
|
+
* remove every flagged id from the person, styling, pose and mood values.
|
|
348
|
+
* Identity (same reference) for an adult. */
|
|
349
|
+
export function applyMinorAgeFloorToPickerValues<T extends Record<string, unknown>>(values: T): T {
|
|
350
|
+
const person = values.person
|
|
351
|
+
if (!person || typeof person !== "object" || !isMinorAge(person as { age?: string; customAge?: number; type?: string })) return values
|
|
352
|
+
const drop = getAdultOnlyIds()
|
|
353
|
+
const out: Record<string, unknown> = { ...values }
|
|
354
|
+
for (const key of FLOORED_PICKER_KEYS) {
|
|
355
|
+
const v = out[key]
|
|
356
|
+
if (v && typeof v === "object" && !Array.isArray(v)) out[key] = stripIds(v as Record<string, unknown>, drop)
|
|
357
|
+
}
|
|
358
|
+
return out as T
|
|
359
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LEAF MODULE — no runtime imports. person.ts and styling.ts import this;
|
|
3
|
+
* any catalog import here reopens the person → age-floor → picker-catalogs
|
|
4
|
+
* load-time cycle.
|
|
5
|
+
*
|
|
6
|
+
* `isMinorAge` is an ADULT ALLOW-LIST on purpose: a new age id added to the
|
|
7
|
+
* catalog is inside the floor until someone lists it here as adult. Custom
|
|
8
|
+
* numeric ages use the same boundary as `buildAgeFragment` in person.ts
|
|
9
|
+
* ("in their teens" below 20).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const ADULT_ONLY_FLAG = "adultOnly" as const
|
|
13
|
+
|
|
14
|
+
/** Catalog age ids that describe an ADULT. Everything else is floored. */
|
|
15
|
+
export const ADULT_AGE_IDS: ReadonlySet<string> = new Set([
|
|
16
|
+
"age-early-20s", "age-late-20s", "age-20s", "age-30s", "age-40s", "age-50s", "age-60s", "age-elderly",
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
/** Type entries whose hint reads as a child even with no age selected. */
|
|
20
|
+
export const MINOR_IMPLYING_TYPE_IDS: ReadonlySet<string> = new Set([
|
|
21
|
+
"alice-wonderland", "dorothy-oz", "peter-pan", "magical-girl", "prince", "princess",
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
/** True when the picker value describes someone under 20, or a child-typed
|
|
25
|
+
* subject with no age. An explicit adult age always wins. */
|
|
26
|
+
export function isMinorAge(
|
|
27
|
+
value: { readonly age?: string; readonly customAge?: number; readonly type?: string } | null | undefined,
|
|
28
|
+
): boolean {
|
|
29
|
+
if (!value) return false
|
|
30
|
+
const { age, customAge, type } = value
|
|
31
|
+
if (typeof age === "string" && age.length > 0) {
|
|
32
|
+
if (age === "age-custom") return !(typeof customAge === "number" && Number.isFinite(customAge) && customAge >= 20)
|
|
33
|
+
return !ADULT_AGE_IDS.has(age)
|
|
34
|
+
}
|
|
35
|
+
return typeof type === "string" && MINOR_IMPLYING_TYPE_IDS.has(type)
|
|
36
|
+
}
|
package/src/atmosphere.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { resolveTerm, type PickerHintMode } from "./term.js"
|
|
15
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
15
16
|
|
|
16
17
|
export interface Atmosphere {
|
|
17
18
|
readonly id: string
|
|
@@ -73,7 +74,7 @@ const atmosphereById = new Map<string, Atmosphere>(ATMOSPHERES.map((a) => [a.id,
|
|
|
73
74
|
|
|
74
75
|
export function getAtmosphere(id: string | undefined | null): Atmosphere | undefined {
|
|
75
76
|
if (!id) return undefined
|
|
76
|
-
return atmosphereById.get(id)
|
|
77
|
+
return overlayEntry("atmosphere", id, atmosphereById.get(id))
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
export function getAtmosphereLabel(id: string | undefined | null, fallback?: string): string {
|
package/src/backdrop.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { resolveTerm } from "./term.js"
|
|
26
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
26
27
|
|
|
27
28
|
export type BackdropCategory =
|
|
28
29
|
| "solid"
|
|
@@ -105,7 +106,7 @@ const backdropById = new Map<string, Backdrop>(BACKDROPS.map((b) => [b.id, b]))
|
|
|
105
106
|
|
|
106
107
|
export function getBackdrop(id: string | undefined | null): Backdrop | undefined {
|
|
107
108
|
if (!id) return undefined
|
|
108
|
-
return backdropById.get(id)
|
|
109
|
+
return overlayEntry("backdrop", id, backdropById.get(id))
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
export function getBackdropLabel(id: string | undefined | null, fallback?: string): string {
|
package/src/camera-format.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { resolveTerm } from "./term.js"
|
|
13
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
13
14
|
|
|
14
15
|
export interface CameraFormat {
|
|
15
16
|
readonly id: string
|
|
@@ -76,7 +77,7 @@ const formatById = new Map<string, CameraFormat>(CAMERA_FORMATS.map((f) => [f.id
|
|
|
76
77
|
|
|
77
78
|
export function getCameraFormat(id: string | undefined | null): CameraFormat | undefined {
|
|
78
79
|
if (!id) return undefined
|
|
79
|
-
return formatById.get(id)
|
|
80
|
+
return overlayEntry("camera-format", id, formatById.get(id))
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export function getCameraFormatLabel(id: string | undefined | null, fallback?: string): string {
|
package/src/camera-motions.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { resolveTerm, type PickerHintMode } from "./term.js"
|
|
11
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
11
12
|
|
|
12
13
|
export type CameraMotionCategory =
|
|
13
14
|
| "default"
|
|
@@ -605,7 +606,7 @@ const motionById = new Map<string, CameraMotion>(
|
|
|
605
606
|
|
|
606
607
|
export function getCameraMotion(id: string | undefined | null): CameraMotion | undefined {
|
|
607
608
|
if (!id) return undefined
|
|
608
|
-
return motionById.get(id)
|
|
609
|
+
return overlayEntry("camera-motions", id, motionById.get(id))
|
|
609
610
|
}
|
|
610
611
|
|
|
611
612
|
/** Human-readable label for the given motion id. Falls back to the id if unknown. */
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { PICKER_CATALOGS, getPickerCatalog } from "./picker-catalogs.js"
|
|
2
|
+
import { getRegisteredCatalogPacks, catalogPacksVersion } from "./catalog-packs.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* THE WALL: refuse work that names a catalog id this deployment does not
|
|
6
|
+
* offer.
|
|
7
|
+
*
|
|
8
|
+
* A curated deployment composes its picker catalogs from packs — entries
|
|
9
|
+
* removed, some rewritten. The browser is supposed to show only the composed
|
|
10
|
+
* catalogs and the resolvers are supposed to honor them, but neither of those
|
|
11
|
+
* is a SAFETY property: a stale tab, an imported workflow, a hand-made request
|
|
12
|
+
* or a bug in either layer can still hand the run path an id the deployment
|
|
13
|
+
* never offered. This module is what makes curation hold regardless — it is
|
|
14
|
+
* consulted at the one place every run passes (the orchestrator, after input
|
|
15
|
+
* overrides are merged), inside nested sub-workflows, and on the single-node
|
|
16
|
+
* routes that carry ids on the wire.
|
|
17
|
+
*
|
|
18
|
+
* INERT WITHOUT PACKS. A deployment that registers no catalog packs offers
|
|
19
|
+
* every base id, so there is nothing to refuse; the guard returns [] without
|
|
20
|
+
* walking. Mainline behavior is unchanged by construction.
|
|
21
|
+
*
|
|
22
|
+
* WHAT COUNTS AS AN ID FIELD. Every field a picker catalog declares — a
|
|
23
|
+
* single-dim catalog's `valueField`, a multi-dim catalog's `dimensions[].field`
|
|
24
|
+
* — plus the few resolver side-fields the catalogs do not declare (the pose
|
|
25
|
+
* sub-picks, the legacy direction keys). A field NOT in that table is never
|
|
26
|
+
* validated: `preText`, `customText`, `customAge`, free-text notes and the
|
|
27
|
+
* like are not ids and must not be refused. The table is derived from the
|
|
28
|
+
* BASE catalogs, so a field a `replace` pack dropped is still recognised as an
|
|
29
|
+
* id field and any value in it fails membership — which is the point.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export interface ForeignCatalogId {
|
|
33
|
+
/** The node carrying the id (undefined for a request body). */
|
|
34
|
+
readonly nodeId?: string
|
|
35
|
+
readonly nodeType: string
|
|
36
|
+
readonly field: string
|
|
37
|
+
readonly id: string
|
|
38
|
+
readonly catalogId: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Node-data fields that resolve against a catalog but are not declared by
|
|
42
|
+
* it. Kept beside the guard so a new one is a one-line, reviewed addition. */
|
|
43
|
+
const SIDE_FIELDS: ReadonlyArray<readonly [nodeType: string, field: string, catalogId: string]> = [
|
|
44
|
+
// pose.ts resolves all four sub-picks through getPosePromptHint
|
|
45
|
+
["pose", "handPosition", "pose"],
|
|
46
|
+
["pose", "bodyLean", "pose"],
|
|
47
|
+
["pose", "headTilt", "pose"],
|
|
48
|
+
["pose", "activity", "pose"],
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/** Legacy `direction` wire keys still accepted by direction-registry.ts. */
|
|
52
|
+
const DIRECTION_ALIASES: Readonly<Record<string, string>> = {
|
|
53
|
+
framingId: "framing",
|
|
54
|
+
framingAngleId: "framing",
|
|
55
|
+
lightingId: "lighting",
|
|
56
|
+
lensId: "lens",
|
|
57
|
+
cameraFormatId: "camera-format",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface FieldTable {
|
|
61
|
+
/** nodeType → (field → catalogId) */
|
|
62
|
+
readonly byNodeType: ReadonlyMap<string, ReadonlyMap<string, string>>
|
|
63
|
+
/** field → catalogId, across every catalog (for direction/subject bodies) */
|
|
64
|
+
readonly byField: ReadonlyMap<string, string>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let tableMemo: FieldTable | null = null
|
|
68
|
+
/** Derived from the BASE catalogs once — the field contract is code, not packs. */
|
|
69
|
+
function fieldTable(): FieldTable {
|
|
70
|
+
if (tableMemo) return tableMemo
|
|
71
|
+
const byNodeType = new Map<string, Map<string, string>>()
|
|
72
|
+
const byField = new Map<string, string>()
|
|
73
|
+
const put = (nodeType: string, field: string, catalogId: string) => {
|
|
74
|
+
let m = byNodeType.get(nodeType)
|
|
75
|
+
if (!m) byNodeType.set(nodeType, (m = new Map()))
|
|
76
|
+
m.set(field, catalogId)
|
|
77
|
+
if (!byField.has(field)) byField.set(field, catalogId)
|
|
78
|
+
}
|
|
79
|
+
for (const c of PICKER_CATALOGS) {
|
|
80
|
+
if (c.valueField) put(c.nodeType, c.valueField, c.catalogId)
|
|
81
|
+
for (const d of c.dimensions ?? []) put(c.nodeType, d.field, c.catalogId)
|
|
82
|
+
for (const f of c.fields ?? []) put(c.nodeType, f, c.catalogId)
|
|
83
|
+
}
|
|
84
|
+
for (const [nodeType, field, catalogId] of SIDE_FIELDS) put(nodeType, field, catalogId)
|
|
85
|
+
for (const [alias, catalogId] of Object.entries(DIRECTION_ALIASES)) if (!byField.has(alias)) byField.set(alias, catalogId)
|
|
86
|
+
tableMemo = { byNodeType, byField }
|
|
87
|
+
return tableMemo
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let idsMemo: { v: number; byCatalog: Map<string, ReadonlySet<string>> } | null = null
|
|
91
|
+
/** Every id the COMPOSED catalog offers, across options and all dimensions. */
|
|
92
|
+
function composedIds(catalogId: string): ReadonlySet<string> {
|
|
93
|
+
const v = catalogPacksVersion()
|
|
94
|
+
if (!idsMemo || idsMemo.v !== v) idsMemo = { v, byCatalog: new Map() }
|
|
95
|
+
const hit = idsMemo.byCatalog.get(catalogId)
|
|
96
|
+
if (hit) return hit
|
|
97
|
+
const cat = getPickerCatalog(catalogId)
|
|
98
|
+
const ids = new Set<string>()
|
|
99
|
+
for (const o of cat?.options ?? []) ids.add(o.id)
|
|
100
|
+
for (const d of cat?.dimensions ?? []) for (const o of d.options) ids.add(o.id)
|
|
101
|
+
idsMemo.byCatalog.set(catalogId, ids)
|
|
102
|
+
return ids
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function values(v: unknown): string[] {
|
|
106
|
+
if (typeof v === "string") return v ? [v] : []
|
|
107
|
+
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string" && x.length > 0)
|
|
108
|
+
return []
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The guard is only meaningful once something curates. */
|
|
112
|
+
export function catalogGuardActive(): boolean {
|
|
113
|
+
return getRegisteredCatalogPacks().length > 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function checkRecord(
|
|
117
|
+
nodeType: string,
|
|
118
|
+
nodeId: string | undefined,
|
|
119
|
+
data: Record<string, unknown>,
|
|
120
|
+
fields: ReadonlyMap<string, string>,
|
|
121
|
+
out: ForeignCatalogId[],
|
|
122
|
+
): void {
|
|
123
|
+
for (const [field, catalogId] of fields) {
|
|
124
|
+
const raw = data[field]
|
|
125
|
+
if (raw === undefined || raw === null) continue
|
|
126
|
+
const offered = composedIds(catalogId)
|
|
127
|
+
for (const id of values(raw)) {
|
|
128
|
+
if (!offered.has(id)) out.push({ nodeId, nodeType, field, id, catalogId })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Walk a graph. Parameter nodes are checked against their own catalog's
|
|
135
|
+
* fields; consumer nodes (generate-image, image-to-video, …) carry folded
|
|
136
|
+
* `direction` / `subject` records whose keys are the same field names, so
|
|
137
|
+
* those are checked against the global field table.
|
|
138
|
+
*/
|
|
139
|
+
export function findForeignCatalogIds(
|
|
140
|
+
nodes: ReadonlyArray<{ id?: unknown; type?: unknown; data?: unknown }> | undefined,
|
|
141
|
+
): ForeignCatalogId[] {
|
|
142
|
+
if (!catalogGuardActive() || !nodes) return []
|
|
143
|
+
const table = fieldTable()
|
|
144
|
+
const out: ForeignCatalogId[] = []
|
|
145
|
+
for (const node of nodes) {
|
|
146
|
+
const type = typeof node.type === "string" ? node.type : ""
|
|
147
|
+
const nodeId = typeof node.id === "string" ? node.id : undefined
|
|
148
|
+
const data = node.data && typeof node.data === "object" ? (node.data as Record<string, unknown>) : null
|
|
149
|
+
if (!type || !data) continue
|
|
150
|
+
const own = table.byNodeType.get(type)
|
|
151
|
+
if (own) checkRecord(type, nodeId, data, own, out)
|
|
152
|
+
for (const key of ["direction", "subject"] as const) {
|
|
153
|
+
const rec = data[key]
|
|
154
|
+
if (rec && typeof rec === "object" && !Array.isArray(rec)) {
|
|
155
|
+
checkRecord(type, nodeId, rec as Record<string, unknown>, table.byField, out)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A request body's `direction` / `subject` records (the single-node routes). */
|
|
163
|
+
export function findForeignCatalogIdsInBody(
|
|
164
|
+
nodeType: string,
|
|
165
|
+
body: { direction?: unknown; subject?: unknown } | undefined,
|
|
166
|
+
): ForeignCatalogId[] {
|
|
167
|
+
if (!catalogGuardActive() || !body) return []
|
|
168
|
+
const table = fieldTable()
|
|
169
|
+
const out: ForeignCatalogId[] = []
|
|
170
|
+
for (const key of ["direction", "subject"] as const) {
|
|
171
|
+
const rec = body[key]
|
|
172
|
+
if (rec && typeof rec === "object" && !Array.isArray(rec)) {
|
|
173
|
+
checkRecord(nodeType, undefined, rec as Record<string, unknown>, table.byField, out)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One line, naming every offender — the audit trail and the user message. */
|
|
180
|
+
export function foreignCatalogIdMessage(found: readonly ForeignCatalogId[]): string {
|
|
181
|
+
const parts = [...new Set(found.map((f) => `${f.field}="${f.id}"`))]
|
|
182
|
+
return `This deployment does not offer the following picker value${parts.length === 1 ? "" : "s"}: ${parts.join(", ")}. Choose from the options shown in the picker.`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Test hook. */
|
|
186
|
+
export function __resetCatalogIdGuardForTests(): void {
|
|
187
|
+
tableMemo = null
|
|
188
|
+
idsMemo = null
|
|
189
|
+
}
|