@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/picker-catalogs.ts
CHANGED
|
@@ -87,6 +87,7 @@ import { INSTRUMENTS, PRODUCTION_STYLES, VOCAL_PRESENCE, SINGING_STYLES } from "
|
|
|
87
87
|
import { VOICE_AGES, VOICE_GENDERS, VOICE_LANGUAGES, VOICE_ACCENTS, VOICE_TIMBRES } from "./voice-character.js"
|
|
88
88
|
import { VOICE_PACES, VOICE_EMOTIONS, VOICE_ARCHETYPES } from "./voice-delivery.js"
|
|
89
89
|
import { composePickerCatalogs, getRegisteredCatalogPacks, catalogPacksVersion } from "./catalog-packs.js"
|
|
90
|
+
import { setComposedCatalogResolver } from "./catalog-overlay.js"
|
|
90
91
|
import { deriveTerm, resolveTerm } from "./term.js"
|
|
91
92
|
|
|
92
93
|
export interface PickerOption {
|
|
@@ -96,6 +97,8 @@ export interface PickerOption {
|
|
|
96
97
|
/** The group id (matches `categoryOrder` / `categoryLabels`). */
|
|
97
98
|
readonly category?: string
|
|
98
99
|
readonly promptHint: string
|
|
100
|
+
/** W1-a: see Person.adultOnly. Carried verbatim through packs. */
|
|
101
|
+
readonly adultOnly?: true
|
|
99
102
|
/**
|
|
100
103
|
* The short professional term this id injects in COMPACT hint mode ("whip
|
|
101
104
|
* pan left" where `promptHint` is the full mechanism sentence). Always
|
|
@@ -156,6 +159,8 @@ interface BaseCatalogEntry {
|
|
|
156
159
|
readonly promptHint: string
|
|
157
160
|
/** Optional authored compact term (see `term.ts` for the convention). */
|
|
158
161
|
readonly term?: string
|
|
162
|
+
/** W1-a: see Person.adultOnly. Propagated verbatim into the flattened option. */
|
|
163
|
+
readonly adultOnly?: true
|
|
159
164
|
}
|
|
160
165
|
|
|
161
166
|
/**
|
|
@@ -183,6 +188,7 @@ function toOptions<T extends BaseCatalogEntry>(
|
|
|
183
188
|
}
|
|
184
189
|
if (e.description) opt.description = e.description
|
|
185
190
|
if (categoryField) opt.category = e[categoryField] as unknown as string
|
|
191
|
+
if (e.adultOnly) opt.adultOnly = true
|
|
186
192
|
return opt as PickerOption
|
|
187
193
|
})
|
|
188
194
|
}
|
|
@@ -836,6 +842,11 @@ export function getPickerCatalog(nodeTypeOrCatalogId: string): PickerCatalog | u
|
|
|
836
842
|
)
|
|
837
843
|
}
|
|
838
844
|
|
|
845
|
+
// Install the composed view into the per-catalog getters (catalog-overlay.ts).
|
|
846
|
+
// Done here, at the one module that already imports every catalog, so the
|
|
847
|
+
// catalog modules themselves never import upward.
|
|
848
|
+
setComposedCatalogResolver(getPickerCatalog)
|
|
849
|
+
|
|
839
850
|
export function listPickerCatalogs(): readonly PickerCatalog[] {
|
|
840
851
|
return getRegisteredPickerCatalogs()
|
|
841
852
|
}
|
package/src/pose.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { resolveTerm, type PickerHintMode } from "./term.js"
|
|
24
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
24
25
|
|
|
25
26
|
export type PoseCategory =
|
|
26
27
|
| "standing"
|
|
@@ -39,6 +40,16 @@ export interface Pose {
|
|
|
39
40
|
readonly category: PoseCategory
|
|
40
41
|
readonly description: string
|
|
41
42
|
readonly promptHint: string
|
|
43
|
+
/**
|
|
44
|
+
* W1-a minor-age floor (spec 2026-09-01 §3.3). `true` marks an entry whose
|
|
45
|
+
* hint describes body exposure, sheer/wet clothing, swimwear/lingerie, a
|
|
46
|
+
* seductive expression or gaze, or a body-placed tattoo. The fragment
|
|
47
|
+
* collectors DROP flagged entries for a minor subject (`isMinorAge`), the
|
|
48
|
+
* picker hides their tiles, the analyzer never emits them, and the backend
|
|
49
|
+
* policy strips their wording from free text. Hand-curated; the
|
|
50
|
+
* `adult-only-ratchet` test only ratchets. Never set on neutral defaults.
|
|
51
|
+
*/
|
|
52
|
+
readonly adultOnly?: true
|
|
42
53
|
/**
|
|
43
54
|
* Compact professional term injected instead of `promptHint` in compact hint
|
|
44
55
|
* mode. Authored only where the lowercased label is not what a photographer
|
|
@@ -68,8 +79,8 @@ export const POSES: ReadonlyArray<Pose> = [
|
|
|
68
79
|
{ id: "cross-legged", label: "Cross-legged", category: "seated", description: "Seated cross-legged on floor", promptHint: "sitting cross-legged on the ground", term: "sitting cross-legged" },
|
|
69
80
|
{ id: "kneeling", label: "Kneeling", category: "seated", description: "Kneeling on the ground", promptHint: "kneeling on one or both knees" },
|
|
70
81
|
{ id: "crouching", label: "Crouching", category: "seated", description: "Crouched low", promptHint: "crouched low with knees bent" },
|
|
71
|
-
{ id: "lounging", label: "Lounging", category: "seated", description: "Reclined, relaxed sitting", promptHint: "lounging in a relaxed, reclined position" },
|
|
72
|
-
{ id: "sitting-edge-of-bed", label: "Sitting on Edge of Bed", category: "seated", description: "Perched on the edge of a bed", promptHint: "perched on the edge of a bed, hands resting on the mattress", term: "sitting on the edge of a bed" },
|
|
82
|
+
{ id: "lounging", label: "Lounging", category: "seated", description: "Reclined, relaxed sitting", promptHint: "lounging in a relaxed, reclined position" , adultOnly: true },
|
|
83
|
+
{ id: "sitting-edge-of-bed", label: "Sitting on Edge of Bed", category: "seated", description: "Perched on the edge of a bed", promptHint: "perched on the edge of a bed, hands resting on the mattress", term: "sitting on the edge of a bed" , adultOnly: true },
|
|
73
84
|
{ id: "chair-arm-drape", label: "Legs Draped Over Chair", category: "seated", description: "Legs draped over chair arm", promptHint: "sitting sideways in a chair with legs draped casually over one arm", term: "legs draped over the chair arm" },
|
|
74
85
|
{ id: "elbow-propped", label: "Cheek on Propped Elbow", category: "seated", description: "Cheek resting on a propped elbow", promptHint: "seated with cheek resting against a propped-up elbow, contemplative", term: "cheek resting on a propped elbow" },
|
|
75
86
|
{ id: "lying-on-stomach-reading", label: "Lying Prone Reading", category: "seated", description: "Lying prone, propped on elbows reading", promptHint: "lying on the stomach, propped up on elbows while reading", term: "lying prone propped on elbows, reading" },
|
|
@@ -94,14 +105,14 @@ export const POSES: ReadonlyArray<Pose> = [
|
|
|
94
105
|
{ id: "throwing", label: "Throwing", category: "action", description: "Mid-throw motion", promptHint: "caught mid-throw, body coiled and releasing", term: "caught mid-throw" },
|
|
95
106
|
{ id: "leaping", label: "Leaping", category: "action", description: "Leaping forward dynamically", promptHint: "leaping forward dynamically with body extended" },
|
|
96
107
|
{ id: "dramatic-action", label: "Dramatic Action", category: "action", description: "Exaggerated action pose", promptHint: "in a dramatic, exaggerated action pose full of motion", term: "dramatic exaggerated action pose" },
|
|
97
|
-
{ id: "biting-lip", label: "Biting Lip", category: "action", description: "Slight playful lip-bite", promptHint: "biting the lower lip
|
|
108
|
+
{ id: "biting-lip", label: "Biting Lip", category: "action", description: "Slight playful lip-bite", promptHint: "lightly biting the lower lip, a subtle playful expression", term: "biting lower lip" , adultOnly: true },
|
|
98
109
|
{ id: "mid-laugh", label: "Mid-Laugh", category: "action", description: "Caught mid-laugh, head back", promptHint: "caught mid-laugh with head tipped back, eyes crinkled" },
|
|
99
110
|
{ id: "pointing-at-camera", label: "Pointing at Camera", category: "action", description: "Pointing directly at camera", promptHint: "pointing one finger directly at the camera, arm extended", term: "pointing directly at the camera" },
|
|
100
111
|
{ id: "tongue-out", label: "Sticking Tongue Out", category: "action", description: "Playful tongue-out expression", promptHint: "sticking the tongue out playfully" },
|
|
101
112
|
{ id: "thinking", label: "Thinking", category: "action", description: "Hand on chin, contemplative", promptHint: "in a thinking pose with hand on chin, gaze contemplative", term: "thinking pose, hand on chin" },
|
|
102
113
|
|
|
103
114
|
// -------------------- Resting --------------------
|
|
104
|
-
{ id: "lying-down", label: "Lying Down", category: "resting", description: "Lying flat", promptHint: "lying down flat, relaxed" },
|
|
115
|
+
{ id: "lying-down", label: "Lying Down", category: "resting", description: "Lying flat", promptHint: "lying down flat, relaxed" , adultOnly: true },
|
|
105
116
|
{ id: "sleeping", label: "Sleeping", category: "resting", description: "Eyes closed, sleeping", promptHint: "sleeping peacefully with eyes closed" },
|
|
106
117
|
{ id: "hugging", label: "Hugging", category: "resting", description: "Embracing another", promptHint: "hugging or embracing another person" },
|
|
107
118
|
{ id: "looking-away", label: "Looking Away", category: "resting", description: "Head turned, looking away", promptHint: "head turned, looking off away from the camera" },
|
|
@@ -125,7 +136,7 @@ export const POSES: ReadonlyArray<Pose> = [
|
|
|
125
136
|
{ id: "leaning-back", label: "Leaning Back", category: "body-lean", description: "Torso leaning back slightly", promptHint: "with the torso leaning back at a slight backward angle", term: "torso leaning slightly back" },
|
|
126
137
|
{ id: "leaning-forward", label: "Leaning Forward", category: "body-lean", description: "Torso leaning toward camera", promptHint: "with the torso leaning forward toward the camera", term: "torso leaning forward" },
|
|
127
138
|
{ id: "body-lean-contrapposto", label: "Contrapposto", category: "body-lean", description: "Weight on one leg, hip pushed out", promptHint: "with the weight on one leg and one hip pushed out, classical contrapposto", term: "contrapposto, hip pushed out" },
|
|
128
|
-
{ id: "arched-back", label: "Arched Back", category: "body-lean", description: "Back gently arched, chest forward", promptHint: "with the back gently arched, chest forward" },
|
|
139
|
+
{ id: "arched-back", label: "Arched Back", category: "body-lean", description: "Back gently arched, chest forward", promptHint: "with the back gently arched, chest forward" , adultOnly: true },
|
|
129
140
|
{ id: "shoulder-rolled-forward", label: "Shoulder Rolled Forward", category: "body-lean", description: "One shoulder rolled forward", promptHint: "with one shoulder rolled forward, asymmetric stance", term: "one shoulder rolled forward" },
|
|
130
141
|
|
|
131
142
|
// -------------------- Head Tilt --------------------
|
|
@@ -155,7 +166,7 @@ const poseById = new Map<string, Pose>(POSES.map((p) => [p.id, p]))
|
|
|
155
166
|
|
|
156
167
|
export function getPose(id: string | undefined | null): Pose | undefined {
|
|
157
168
|
if (!id) return undefined
|
|
158
|
-
return poseById.get(id)
|
|
169
|
+
return overlayEntry("pose", id, poseById.get(id))
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
export function getPoseLabel(id: string | undefined | null, fallback?: string): string {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { resolveTerm, type PickerHintMode } from "./term.js"
|
|
25
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
25
26
|
|
|
26
27
|
export interface PostProcessEffect {
|
|
27
28
|
readonly id: string
|
|
@@ -65,7 +66,7 @@ const postProcessById = new Map<string, PostProcessEffect>(
|
|
|
65
66
|
|
|
66
67
|
export function getPostProcessEffect(id: string | undefined | null): PostProcessEffect | undefined {
|
|
67
68
|
if (!id) return undefined
|
|
68
|
-
return postProcessById.get(id)
|
|
69
|
+
return overlayEntry("post-process-effects", id, postProcessById.get(id))
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export function getPostProcessEffectLabel(id: string | undefined | null, fallback?: string): string {
|
package/src/prompt-builder.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { resolveTemplate, applyTemplate } from "./prompt-templates.js"
|
|
8
8
|
import { NATIVE_NEGATIVE_PROMPT_MODELS, MODELS_WITH_REFERENCE_IMAGE_SUPPORT, imageReferenceLimit, getMaxImagePromptChars, getMaxNegativePromptChars } from "@nodaro/shared"
|
|
9
|
-
import { getStylePromptHint } from "./style.js"
|
|
9
|
+
import { getStylePromptHint, isDeniedStyleId } from "./style.js"
|
|
10
10
|
import {
|
|
11
11
|
STYLE_SECTION_GAP,
|
|
12
12
|
STYLE_SECTION_HEADER,
|
|
@@ -2812,7 +2812,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2812
2812
|
}
|
|
2813
2813
|
|
|
2814
2814
|
const styleText = style?.trim()
|
|
2815
|
-
const styleLine = styleText ? `Style: ${getStylePromptHint(styleText) || styleText}` : ""
|
|
2815
|
+
const styleLine = styleText && !isDeniedStyleId(styleText) ? `Style: ${getStylePromptHint(styleText) || styleText}` : ""
|
|
2816
2816
|
|
|
2817
2817
|
const negPrompt = negativePrompt?.trim()
|
|
2818
2818
|
let nativeNegativePrompt: string | undefined
|
|
@@ -2937,7 +2937,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2937
2937
|
// the richer promptHint; otherwise fall back to the raw text (covers custom
|
|
2938
2938
|
// free-text styles that don't match a preset).
|
|
2939
2939
|
const styleText = style?.trim()
|
|
2940
|
-
const styleLine = styleText ? `Style: ${getStylePromptHint(styleText) || styleText}` : ""
|
|
2940
|
+
const styleLine = styleText && !isDeniedStyleId(styleText) ? `Style: ${getStylePromptHint(styleText) || styleText}` : ""
|
|
2941
2941
|
|
|
2942
2942
|
// Handle negative prompt: native support vs prompt-appended
|
|
2943
2943
|
const negPrompt = negativePrompt?.trim()
|
package/src/render-quality.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { resolveTerm } from "./term.js"
|
|
27
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
27
28
|
|
|
28
29
|
export interface RenderQuality {
|
|
29
30
|
readonly id: string
|
|
@@ -85,7 +86,7 @@ const renderQualityById = new Map<string, RenderQuality>(
|
|
|
85
86
|
|
|
86
87
|
export function getRenderQuality(id: string | undefined | null): RenderQuality | undefined {
|
|
87
88
|
if (!id) return undefined
|
|
88
|
-
return renderQualityById.get(id)
|
|
89
|
+
return overlayEntry("render-quality", id, renderQualityById.get(id))
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
export function getRenderQualityLabel(id: string | undefined | null, fallback?: string): string {
|
package/src/setting.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { resolveTerm } from "./term.js"
|
|
23
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
23
24
|
|
|
24
25
|
export type SettingCategory = "indoor" | "urban" | "nature" | "fantastical"
|
|
25
26
|
|
|
@@ -118,7 +119,7 @@ const settingById = new Map<string, Setting>(SETTINGS.map((s) => [s.id, s]))
|
|
|
118
119
|
|
|
119
120
|
export function getSetting(id: string | undefined | null): Setting | undefined {
|
|
120
121
|
if (!id) return undefined
|
|
121
|
-
return settingById.get(id)
|
|
122
|
+
return overlayEntry("setting", id, settingById.get(id))
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
export function getSettingLabel(id: string | undefined | null, fallback?: string): string {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { getAnimalPromptHint, getAnimalTerm, getVehicle, getWeapon, getFurniture } from "@nodaro/shared"
|
|
2
|
+
import { composedHas, composedOption } from "./catalog-overlay.js"
|
|
3
|
+
import { deriveTerm } from "./term.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The four object-entity catalogs — animals, vehicles, weapons, furniture —
|
|
7
|
+
* are OWNED by `@nodaro/shared`, which cannot depend on this package, so
|
|
8
|
+
* their getters cannot carry the `overlayEntry` the other 34 catalogs do.
|
|
9
|
+
* These wrappers are the overlay for them, and EVERY prompt-text read of
|
|
10
|
+
* those catalogs goes through here: the parameter-node dispatcher and the
|
|
11
|
+
* subject fold both. Two call sites resolving the same id to different text
|
|
12
|
+
* — one curated, one stock — is exactly what a second, un-overlaid path
|
|
13
|
+
* produced during review.
|
|
14
|
+
*
|
|
15
|
+
* Precedence per id, same four outcomes as catalog-overlay.ts:
|
|
16
|
+
* - no pack on the catalog → the stock getter, untouched
|
|
17
|
+
* - denied / not in the pack → "" (nothing reaches the prompt)
|
|
18
|
+
* - composed option present → ITS promptHint / term. A pack author
|
|
19
|
+
* who rewrote an entry wrote the hint they want injected; rebuilding it
|
|
20
|
+
* from label + description would discard that
|
|
21
|
+
* - pack-added id (no stock row) → the composed option (covered by the
|
|
22
|
+
* branch above — the stock getter is never consulted when a pack exists)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export function curatedAnimalPromptHint(id: string): string {
|
|
26
|
+
if (!id) return ""
|
|
27
|
+
if (!composedHas("animals", id)) return ""
|
|
28
|
+
return composedOption("animals", id)?.promptHint ?? getAnimalPromptHint(id)
|
|
29
|
+
}
|
|
30
|
+
export function curatedAnimalTerm(id: string): string {
|
|
31
|
+
if (!id) return ""
|
|
32
|
+
if (!composedHas("animals", id)) return ""
|
|
33
|
+
return composedOption("animals", id)?.term ?? getAnimalTerm(id)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type ObjectEntity = { readonly label: string; readonly description: string }
|
|
37
|
+
|
|
38
|
+
function objectEntityText(
|
|
39
|
+
catalogId: string,
|
|
40
|
+
id: string,
|
|
41
|
+
stock: ObjectEntity | undefined,
|
|
42
|
+
stockHint: (e: ObjectEntity) => string,
|
|
43
|
+
stockTerm: (e: ObjectEntity) => string,
|
|
44
|
+
): { readonly hint: string; readonly term: string } {
|
|
45
|
+
if (!id || !composedHas(catalogId, id)) return { hint: "", term: "" }
|
|
46
|
+
const composed = composedOption(catalogId, id)
|
|
47
|
+
if (composed) return { hint: composed.promptHint, term: composed.term }
|
|
48
|
+
if (!stock) return { hint: "", term: "" }
|
|
49
|
+
return { hint: stockHint(stock), term: stockTerm(stock) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The compact fragment for an OBJECT-entity entry (animal / vehicle / weapon /
|
|
54
|
+
* furniture). Those catalogs carry no `promptHint` of their own — the full
|
|
55
|
+
* fragment is synthesized as "featuring a {label}, {description}" — so the
|
|
56
|
+
* compact form is the authored `term` when there is one and the derived label
|
|
57
|
+
* otherwise (a concrete object's label IS its trade term). The framing verb
|
|
58
|
+
* ("featuring a", "with a") belongs to the HINT; a term drops bare into
|
|
59
|
+
* whatever sentence the consumer is building, and "the object is in the scene"
|
|
60
|
+
* is precisely what these four nodes mean.
|
|
61
|
+
*
|
|
62
|
+
* The fallback MUST be `deriveTerm` and not a bare `toLowerCase()`: it is the
|
|
63
|
+
* same fallback `objectOptions` uses to build the `/v1/catalogs` projection,
|
|
64
|
+
* so a parenthetical label ("Rifle (bolt-action)") must strip identically here
|
|
65
|
+
* or the injected fragment and the projected term would disagree.
|
|
66
|
+
*
|
|
67
|
+
* `term` is read structurally because it is being added to the shared entity
|
|
68
|
+
* interfaces separately; this stays correct before and after that lands.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
function objectEntityTerm(entry: { readonly label: string }): string {
|
|
72
|
+
return (entry as { term?: string }).term ?? deriveTerm(entry.label)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function curatedVehicleText(id: string) {
|
|
76
|
+
return objectEntityText("vehicles", id, getVehicle(id), (e) => `featuring a ${e.label.toLowerCase()}, ${e.description}`, objectEntityTerm)
|
|
77
|
+
}
|
|
78
|
+
export function curatedWeaponText(id: string) {
|
|
79
|
+
return objectEntityText("weapons", id, getWeapon(id), (e) => `with a ${e.label.toLowerCase()}, ${e.description}`, objectEntityTerm)
|
|
80
|
+
}
|
|
81
|
+
export function curatedFurnitureText(id: string) {
|
|
82
|
+
return objectEntityText("furniture", id, getFurniture(id), (e) => `including a ${e.label.toLowerCase()}, ${e.description}`, objectEntityTerm)
|
|
83
|
+
}
|
package/src/style.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { resolveTerm } from "./term.js"
|
|
24
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
24
25
|
|
|
25
26
|
export interface Style {
|
|
26
27
|
readonly id: string
|
|
@@ -96,7 +97,21 @@ const styleById = new Map<string, Style>(STYLES.map((s) => [s.id, s]))
|
|
|
96
97
|
|
|
97
98
|
export function getStyle(id: string | undefined | null): Style | undefined {
|
|
98
99
|
if (!id) return undefined
|
|
99
|
-
return styleById.get(id)
|
|
100
|
+
return overlayEntry("style", id, styleById.get(id))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The inline `style` field on an image node is id-OR-free-text: a catalog id
|
|
105
|
+
* resolves to its hint, anything else is the user's own words and is folded
|
|
106
|
+
* verbatim. That fallback has a hole on a curated deployment — a style id the
|
|
107
|
+
* pack REMOVED resolves to nothing, so the fold would treat it as prose and
|
|
108
|
+
* ship the raw id. This tells the fold apart: true for an id the stock
|
|
109
|
+
* catalog knows but this deployment does not offer. Such a value is neither
|
|
110
|
+
* a hint nor prose; it is dropped.
|
|
111
|
+
*/
|
|
112
|
+
export function isDeniedStyleId(value: string | undefined | null): boolean {
|
|
113
|
+
if (!value) return false
|
|
114
|
+
return styleById.has(value) && getStyle(value) === undefined
|
|
100
115
|
}
|
|
101
116
|
|
|
102
117
|
export function getStyleLabel(id: string | undefined | null, fallback?: string): string {
|
package/src/styling.ts
CHANGED
|
@@ -47,6 +47,15 @@
|
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
49
|
import { resolveTerm, type PickerHintMode } from "./term.js"
|
|
50
|
+
// W1-a minor-age floor: `age-signal.ts` is an import-free leaf (no runtime
|
|
51
|
+
// deps at all), so this import is cycle-safe unconditionally — unlike
|
|
52
|
+
// `age-floor.js`, which pulls in the catalog funnel (`person-packs.js` /
|
|
53
|
+
// `picker-catalogs.js`) and would reopen the person → age-floor →
|
|
54
|
+
// picker-catalogs load-time cycle if imported from here.
|
|
55
|
+
import { isMinorAge } from "./age-signal.js"
|
|
56
|
+
// Same discipline: catalog-overlay.ts imports only the pack registry, never
|
|
57
|
+
// the catalog funnel, so this is cycle-safe from every catalog module.
|
|
58
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
50
59
|
|
|
51
60
|
export type StylingDimension =
|
|
52
61
|
| "makeup"
|
|
@@ -73,6 +82,16 @@ export interface Styling {
|
|
|
73
82
|
readonly dimension: StylingDimension
|
|
74
83
|
readonly description: string
|
|
75
84
|
readonly promptHint: string
|
|
85
|
+
/**
|
|
86
|
+
* W1-a minor-age floor (spec 2026-09-01 §3.3). `true` marks an entry whose
|
|
87
|
+
* hint describes body exposure, sheer/wet clothing, swimwear/lingerie, a
|
|
88
|
+
* seductive expression or gaze, or a body-placed tattoo. The fragment
|
|
89
|
+
* collectors DROP flagged entries for a minor subject (`isMinorAge`), the
|
|
90
|
+
* picker hides their tiles, the analyzer never emits them, and the backend
|
|
91
|
+
* policy strips their wording from free text. Hand-curated; the
|
|
92
|
+
* `adult-only-ratchet` test only ratchets. Never set on neutral defaults.
|
|
93
|
+
*/
|
|
94
|
+
readonly adultOnly?: true
|
|
76
95
|
/**
|
|
77
96
|
* Compact professional term for hint-compact mode (see `term.ts`).
|
|
78
97
|
*
|
|
@@ -220,14 +239,14 @@ export const STYLINGS: ReadonlyArray<Styling> = [
|
|
|
220
239
|
{ id: "jewelry-pearl", label: "Pearl", dimension: "jewelry", description: "Pearl necklace / earrings", promptHint: "wearing a pearl necklace and pearl earrings", term: "pearl jewelry" },
|
|
221
240
|
{ id: "jewelry-chunky", label: "Chunky", dimension: "jewelry", description: "Chunky chains and rings", promptHint: "wearing chunky chains and oversized rings", term: "chunky chains and rings" },
|
|
222
241
|
{ id: "jewelry-smart-watch", label: "Smart Watch", dimension: "jewelry", description: "Modern smart watch on wrist", promptHint: "wearing a modern smart watch on the wrist with a sleek digital face and silicone band" },
|
|
223
|
-
{ id: "jewelry-choker-leather", label: "Leather Choker", dimension: "jewelry", description: "Black leather choker close to throat", promptHint: "wearing a black leather choker fitted close around the throat" },
|
|
224
|
-
{ id: "jewelry-choker-velvet", label: "Velvet Choker", dimension: "jewelry", description: "Velvet ribbon choker, dressy and vintage", promptHint: "wearing a velvet ribbon choker around the throat, a dressy vintage touch with a small pendant at the front" },
|
|
225
|
-
{ id: "jewelry-choker-chain", label: "Chain Choker", dimension: "jewelry", description: "Thin metal-chain choker", promptHint: "wearing a thin metal-chain choker tight around the throat" },
|
|
242
|
+
{ id: "jewelry-choker-leather", label: "Leather Choker", dimension: "jewelry", description: "Black leather choker close to throat", promptHint: "wearing a black leather choker fitted close around the throat" , adultOnly: true },
|
|
243
|
+
{ id: "jewelry-choker-velvet", label: "Velvet Choker", dimension: "jewelry", description: "Velvet ribbon choker, dressy and vintage", promptHint: "wearing a velvet ribbon choker around the throat, a dressy vintage touch with a small pendant at the front" , adultOnly: true },
|
|
244
|
+
{ id: "jewelry-choker-chain", label: "Chain Choker", dimension: "jewelry", description: "Thin metal-chain choker", promptHint: "wearing a thin metal-chain choker tight around the throat" , adultOnly: true },
|
|
226
245
|
{ id: "jewelry-stud-earrings", label: "Stud Earrings", dimension: "jewelry", description: "Small simple studs", promptHint: "wearing small simple stud earrings" },
|
|
227
246
|
{ id: "jewelry-hoop-earrings", label: "Hoop Earrings", dimension: "jewelry", description: "Gold or silver hoops", promptHint: "wearing gold or silver hoop earrings" },
|
|
228
247
|
{ id: "jewelry-ear-cuff", label: "Ear Cuff", dimension: "jewelry", description: "Sculptural cuff climbing the cartilage", promptHint: "wearing a sculptural ear cuff climbing the cartilage of one ear" },
|
|
229
248
|
{ id: "jewelry-septum-ring", label: "Septum Ring", dimension: "jewelry", description: "Septum nose ring", promptHint: "wearing a septum nose ring through the center of the nose" },
|
|
230
|
-
{ id: "jewelry-body-chain", label: "Body Chain", dimension: "jewelry", description: "Thin metallic chain across torso", promptHint: "wearing a thin metallic body chain draped across the torso" },
|
|
249
|
+
{ id: "jewelry-body-chain", label: "Body Chain", dimension: "jewelry", description: "Thin metallic chain across torso", promptHint: "wearing a thin metallic body chain draped across the torso" , adultOnly: true },
|
|
231
250
|
|
|
232
251
|
// -------------------- Nails --------------------
|
|
233
252
|
{ id: "nails-polished", label: "Polished", dimension: "nails", description: "Clear or nude polished nails", promptHint: "with polished nude nails", term: "polished nude nails" },
|
|
@@ -263,10 +282,10 @@ export const STYLINGS: ReadonlyArray<Styling> = [
|
|
|
263
282
|
{ id: "fabric-chiffon", label: "Chiffon", dimension: "fabric", description: "Sheer flowing chiffon", promptHint: "wearing sheer chiffon with a lightweight floating drape and soft translucent layers" },
|
|
264
283
|
{ id: "fabric-fur", label: "Fur", dimension: "fabric", description: "Thick plush fur", promptHint: "wearing thick plush fur with long dense strands and natural variation", term: "fur garment" },
|
|
265
284
|
{ id: "fabric-sequins", label: "Sequins", dimension: "fabric", description: "Sparkling sequin fabric", promptHint: "wearing sparkling sequined fabric catching light with countless tiny reflective facets" },
|
|
266
|
-
{ id: "fabric-latex", label: "Latex", dimension: "fabric", description: "Glossy latex", promptHint: "wearing glossy latex with a high-shine liquid look clinging to the body" },
|
|
285
|
+
{ id: "fabric-latex", label: "Latex", dimension: "fabric", description: "Glossy latex", promptHint: "wearing glossy latex with a high-shine liquid look clinging to the body" , adultOnly: true },
|
|
267
286
|
|
|
268
287
|
// -------------------- Outfit (single-pick complete look — overrides individual pieces) --------------------
|
|
269
|
-
{ id: "outfit-school-uniform", label: "School Uniform", dimension: "outfit", description: "Classic school uniform set", promptHint: "dressed in a classic school uniform — collared blouse, pleated skirt and knee-high socks" },
|
|
288
|
+
{ id: "outfit-school-uniform", label: "School Uniform", dimension: "outfit", description: "Classic school uniform set", promptHint: "dressed in a classic school uniform — collared blouse, pleated skirt and knee-high socks" , adultOnly: true },
|
|
270
289
|
{ id: "outfit-business-suit", label: "Business Suit", dimension: "outfit", description: "Tailored two-piece business suit", promptHint: "dressed in a sharp tailored two-piece business suit with a crisp shirt underneath" },
|
|
271
290
|
{ id: "outfit-tuxedo", label: "Tuxedo", dimension: "outfit", description: "Black-tie tuxedo", promptHint: "dressed in a black tuxedo with satin lapels and a bow tie" },
|
|
272
291
|
{ id: "outfit-evening-gown", label: "Evening Gown", dimension: "outfit", description: "Floor-length formal gown", promptHint: "dressed in a floor-length evening gown with a fitted bodice and a flowing skirt" },
|
|
@@ -280,9 +299,9 @@ export const STYLINGS: ReadonlyArray<Styling> = [
|
|
|
280
299
|
{ id: "outfit-athleisure", label: "Athleisure Set", dimension: "outfit", description: "Matching athleisure top and bottom", promptHint: "dressed in a matching athleisure set — a fitted top and leggings in the same tone" },
|
|
281
300
|
{ id: "outfit-tracksuit", label: "Tracksuit", dimension: "outfit", description: "Two-piece zip-front tracksuit", promptHint: "dressed in a two-piece tracksuit — a zip-front jacket and matching joggers" },
|
|
282
301
|
{ id: "outfit-soccer-jersey", label: "Soccer Jersey", dimension: "outfit", description: "National-team football jersey with crest", promptHint: "wearing a national-team soccer jersey — team colors and trim with the team crest on the chest" },
|
|
283
|
-
{ id: "outfit-bikini", label: "Bikini", dimension: "outfit", description: "Two-piece swimsuit", promptHint: "in a two-piece bikini" },
|
|
284
|
-
{ id: "outfit-one-piece-swim", label: "One-Piece Swimsuit", dimension: "outfit", description: "Fitted one-piece swimsuit", promptHint: "in a fitted one-piece swimsuit" },
|
|
285
|
-
{ id: "outfit-lingerie", label: "Lingerie Set", dimension: "outfit", description: "Matching lingerie set", promptHint: "in a matching lingerie set" },
|
|
302
|
+
{ id: "outfit-bikini", label: "Bikini", dimension: "outfit", description: "Two-piece swimsuit", promptHint: "in a two-piece bikini" , adultOnly: true },
|
|
303
|
+
{ id: "outfit-one-piece-swim", label: "One-Piece Swimsuit", dimension: "outfit", description: "Fitted one-piece swimsuit", promptHint: "in a fitted one-piece swimsuit" , adultOnly: true },
|
|
304
|
+
{ id: "outfit-lingerie", label: "Lingerie Set", dimension: "outfit", description: "Matching lingerie set", promptHint: "in a matching lingerie set" , adultOnly: true },
|
|
286
305
|
{ id: "outfit-pajamas", label: "Pajamas", dimension: "outfit", description: "Soft cotton pajama set", promptHint: "in a soft cotton pajama set — a button-up top and matching pants" },
|
|
287
306
|
{ id: "outfit-bathrobe", label: "Bathrobe", dimension: "outfit", description: "Plush terrycloth robe", promptHint: "wrapped in a plush terrycloth bathrobe tied at the waist" },
|
|
288
307
|
{ id: "outfit-kimono", label: "Kimono", dimension: "outfit", description: "Traditional Japanese kimono", promptHint: "dressed in a traditional Japanese kimono with an obi sash" },
|
|
@@ -316,14 +335,14 @@ export const STYLINGS: ReadonlyArray<Styling> = [
|
|
|
316
335
|
{ id: "top-polo", label: "Polo Shirt", dimension: "top", description: "Short-sleeve polo", promptHint: "wearing a fitted polo shirt with a small ribbed collar and short sleeves" },
|
|
317
336
|
{ id: "top-tube-top", label: "Tube Top", dimension: "top", description: "Strapless tube top", promptHint: "wearing a strapless tube top fitted across the chest" },
|
|
318
337
|
{ id: "top-camisole", label: "Camisole", dimension: "top", description: "Thin satin cami", promptHint: "wearing a thin satin camisole with delicate spaghetti straps" },
|
|
319
|
-
{ id: "top-corset", label: "Corset", dimension: "top", description: "Structured corset top", promptHint: "wearing a structured corset top with boning and visible lacing" },
|
|
320
|
-
{ id: "top-bra-top", label: "Bra Top", dimension: "top", description: "Bra-style top showing midriff", promptHint: "wearing a fitted bra-style top with the midriff exposed" },
|
|
338
|
+
{ id: "top-corset", label: "Corset", dimension: "top", description: "Structured corset top", promptHint: "wearing a structured corset top with boning and visible lacing" , adultOnly: true },
|
|
339
|
+
{ id: "top-bra-top", label: "Bra Top", dimension: "top", description: "Bra-style top showing midriff", promptHint: "wearing a fitted bra-style top with the midriff exposed" , adultOnly: true },
|
|
321
340
|
{ id: "top-sports-bra", label: "Sports Bra", dimension: "top", description: "Athletic sports bra", promptHint: "wearing a structured sports bra with athletic strapping" },
|
|
322
|
-
{ id: "top-bikini-top", label: "Bikini Top", dimension: "top", description: "Triangle bikini top", promptHint: "wearing a triangle bikini top tied behind the neck and back" },
|
|
341
|
+
{ id: "top-bikini-top", label: "Bikini Top", dimension: "top", description: "Triangle bikini top", promptHint: "wearing a triangle bikini top tied behind the neck and back" , adultOnly: true },
|
|
323
342
|
{ id: "top-bodysuit", label: "Bodysuit", dimension: "top", description: "Fitted one-piece torso garment", promptHint: "wearing a fitted bodysuit, a one-piece torso garment snapped at the inseam" },
|
|
324
343
|
{ id: "top-leotard", label: "Leotard", dimension: "top", description: "Dance-style fitted top with sleeves", promptHint: "wearing a dance-style leotard, a fitted one-piece top with sleeves" },
|
|
325
|
-
{ id: "top-mesh-top", label: "Mesh Top", dimension: "top", description: "Sheer mesh fabric top", promptHint: "wearing a sheer mesh top, the open-weave fabric letting skin show through" },
|
|
326
|
-
{ id: "top-sheer-top", label: "Sheer Top", dimension: "top", description: "Translucent fabric top", promptHint: "wearing a sheer translucent top with a soft drape, hinting at the silhouette underneath" },
|
|
344
|
+
{ id: "top-mesh-top", label: "Mesh Top", dimension: "top", description: "Sheer mesh fabric top", promptHint: "wearing a sheer mesh top, the open-weave fabric letting skin show through" , adultOnly: true },
|
|
345
|
+
{ id: "top-sheer-top", label: "Sheer Top", dimension: "top", description: "Translucent fabric top", promptHint: "wearing a sheer translucent top with a soft drape, hinting at the silhouette underneath" , adultOnly: true },
|
|
327
346
|
{ id: "top-bandeau", label: "Bandeau", dimension: "top", description: "Strapless tube top", promptHint: "wearing a strapless bandeau top wrapped across the chest" },
|
|
328
347
|
|
|
329
348
|
// -------------------- Bottom (lower-body garment) --------------------
|
|
@@ -366,11 +385,11 @@ export const STYLINGS: ReadonlyArray<Styling> = [
|
|
|
366
385
|
|
|
367
386
|
// -------------------- Legwear (between bottom and footwear) --------------------
|
|
368
387
|
{ id: "legwear-bare", label: "Bare Legs", dimension: "legwear", description: "Bare legs, no hosiery", promptHint: "with bare legs, no hosiery" },
|
|
369
|
-
{ id: "legwear-sheer-tights", label: "Sheer Tights", dimension: "legwear", description: "Sheer nude pantyhose", promptHint: "with sheer nude pantyhose" },
|
|
388
|
+
{ id: "legwear-sheer-tights", label: "Sheer Tights", dimension: "legwear", description: "Sheer nude pantyhose", promptHint: "with sheer nude pantyhose" , adultOnly: true },
|
|
370
389
|
{ id: "legwear-opaque-tights", label: "Opaque Tights", dimension: "legwear", description: "Solid opaque tights", promptHint: "with opaque black tights covering the legs" },
|
|
371
|
-
{ id: "legwear-fishnets", label: "Fishnets", dimension: "legwear", description: "Diamond-mesh fishnets", promptHint: "with fishnet stockings, the skin showing through the diamond mesh", term: "fishnet stockings" },
|
|
372
|
-
{ id: "legwear-thigh-highs", label: "Thigh-High Stockings", dimension: "legwear", description: "Thigh-high stockings", promptHint: "with thigh-high stockings stopping mid-thigh" },
|
|
373
|
-
{ id: "legwear-lace-top-stockings", label: "Lace-Top Stockings", dimension: "legwear", description: "Lace-banded thigh-highs", promptHint: "with lace-topped thigh-high stockings cinched mid-thigh" },
|
|
390
|
+
{ id: "legwear-fishnets", label: "Fishnets", dimension: "legwear", description: "Diamond-mesh fishnets", promptHint: "with fishnet stockings, the skin showing through the diamond mesh", term: "fishnet stockings" , adultOnly: true },
|
|
391
|
+
{ id: "legwear-thigh-highs", label: "Thigh-High Stockings", dimension: "legwear", description: "Thigh-high stockings", promptHint: "with thigh-high stockings stopping mid-thigh" , adultOnly: true },
|
|
392
|
+
{ id: "legwear-lace-top-stockings", label: "Lace-Top Stockings", dimension: "legwear", description: "Lace-banded thigh-highs", promptHint: "with lace-topped thigh-high stockings cinched mid-thigh" , adultOnly: true },
|
|
374
393
|
{ id: "legwear-knee-highs", label: "Knee-High Socks", dimension: "legwear", description: "Knee-high socks", promptHint: "with knee-high socks pulled up the calf" },
|
|
375
394
|
{ id: "legwear-crew-socks", label: "Crew Socks", dimension: "legwear", description: "Mid-calf crew socks", promptHint: "with simple crew socks visible above the shoes" },
|
|
376
395
|
{ id: "legwear-ankle-socks", label: "Ankle Socks", dimension: "legwear", description: "Low-cut ankle socks", promptHint: "with low-cut ankle socks barely showing above the shoes" },
|
|
@@ -385,7 +404,7 @@ export const STYLINGS: ReadonlyArray<Styling> = [
|
|
|
385
404
|
{ id: "footwear-platforms", label: "Platform Shoes", dimension: "footwear", description: "Tall platform soles", promptHint: "in platform shoes with thick chunky soles" },
|
|
386
405
|
{ id: "footwear-ankle-boots", label: "Ankle Boots", dimension: "footwear", description: "Fitted ankle boots", promptHint: "in fitted black ankle boots" },
|
|
387
406
|
{ id: "footwear-knee-boots", label: "Knee-High Boots", dimension: "footwear", description: "Tall knee-high boots", promptHint: "in tall knee-high leather boots" },
|
|
388
|
-
{ id: "footwear-thigh-high-boots", label: "Thigh-High Boots", dimension: "footwear", description: "Thigh-high boots", promptHint: "in over-the-knee thigh-high leather boots" },
|
|
407
|
+
{ id: "footwear-thigh-high-boots", label: "Thigh-High Boots", dimension: "footwear", description: "Thigh-high boots", promptHint: "in over-the-knee thigh-high leather boots" , adultOnly: true },
|
|
389
408
|
{ id: "footwear-combat-boots", label: "Combat Boots", dimension: "footwear", description: "Laced combat boots", promptHint: "in laced black combat boots with a chunky lugged sole" },
|
|
390
409
|
{ id: "footwear-chelsea-boots", label: "Chelsea Boots", dimension: "footwear", description: "Slip-on Chelsea boots", promptHint: "in clean Chelsea boots with elastic side panels" },
|
|
391
410
|
{ id: "footwear-cowboy-boots", label: "Cowboy Boots", dimension: "footwear", description: "Pointed-toe cowboy boots", promptHint: "in pointed-toe cowboy boots with a stacked heel and decorative stitching" },
|
|
@@ -405,18 +424,18 @@ export const STYLINGS: ReadonlyArray<Styling> = [
|
|
|
405
424
|
|
|
406
425
|
// -------------------- Wardrobe State (modifier — composes with any garment) --------------------
|
|
407
426
|
{ id: "state-oversized", label: "Oversized", dimension: "wardrobe-state", description: "Loose, oversized fit", promptHint: "the clothing loose and oversized, draping freely well past the body", term: "oversized fit" },
|
|
408
|
-
{ id: "state-fitted", label: "Fitted", dimension: "wardrobe-state", description: "Form-fitting silhouette", promptHint: "
|
|
409
|
-
{ id: "state-cropped", label: "Cropped", dimension: "wardrobe-state", description: "Top cropped at midriff", promptHint: "the top cropped above the midriff with the stomach visible", term: "cropped top" },
|
|
410
|
-
{ id: "state-sheer", label: "Sheer", dimension: "wardrobe-state", description: "Translucent fabric", promptHint: "the fabric translucent and sheer, hinting at the silhouette underneath", term: "sheer clothing" },
|
|
411
|
-
{ id: "state-wet", label: "Wet", dimension: "wardrobe-state", description: "Soaked, water-clinging", promptHint: "the clothing soaked
|
|
427
|
+
{ id: "state-fitted", label: "Fitted", dimension: "wardrobe-state", description: "Form-fitting silhouette", promptHint: "tailored, close-fitting clothing", term: "close-fitting clothing" , adultOnly: true },
|
|
428
|
+
{ id: "state-cropped", label: "Cropped", dimension: "wardrobe-state", description: "Top cropped at midriff", promptHint: "the top cropped above the midriff with the stomach visible", term: "cropped top" , adultOnly: true },
|
|
429
|
+
{ id: "state-sheer", label: "Sheer", dimension: "wardrobe-state", description: "Translucent fabric", promptHint: "the fabric translucent and sheer, hinting at the silhouette underneath", term: "sheer clothing" , adultOnly: true },
|
|
430
|
+
{ id: "state-wet", label: "Wet", dimension: "wardrobe-state", description: "Soaked, water-clinging", promptHint: "the clothing soaked through and dripping", term: "soaked clothing" , adultOnly: true },
|
|
412
431
|
{ id: "state-ripped", label: "Ripped", dimension: "wardrobe-state", description: "Torn and frayed", promptHint: "the fabric torn and ripped at the seams, with frayed edges", term: "ripped clothing" },
|
|
413
432
|
{ id: "state-distressed", label: "Distressed", dimension: "wardrobe-state", description: "Worn, faded, weathered", promptHint: "the clothing distressed and weathered, with faded color and worn-down edges", term: "distressed clothing" },
|
|
414
433
|
{ id: "state-vintage", label: "Vintage", dimension: "wardrobe-state", description: "Worn, retro character", promptHint: "the clothing carrying vintage character — softened color, broken-in fit, retro silhouette", term: "vintage clothing" },
|
|
415
434
|
{ id: "state-tucked-in", label: "Tucked In", dimension: "wardrobe-state", description: "Top tucked into bottom", promptHint: "the top neatly tucked into the bottom", term: "top tucked in" },
|
|
416
435
|
{ id: "state-half-tucked", label: "Half-Tucked", dimension: "wardrobe-state", description: "Front-tucked, back loose", promptHint: "the top half-tucked — front neatly tucked in, back hanging loose", term: "top half-tucked" },
|
|
417
|
-
{ id: "state-off-shoulder", label: "Off-Shoulder", dimension: "wardrobe-state", description: "Slipped off one shoulder", promptHint: "the top slipped off one shoulder, baring the skin and collarbone", term: "off-the-shoulder top" },
|
|
418
|
-
{ id: "state-halter-neck", label: "Halter Neck", dimension: "wardrobe-state", description: "Straps tied behind the neck", promptHint: "the top cut as a halter — straps tied behind the neck, shoulders and upper back bare", term: "halter neckline" },
|
|
419
|
-
{ id: "state-plunging-neck", label: "Plunging Neckline", dimension: "wardrobe-state", description: "Deep plunging V neckline", promptHint: "the neckline cut in a deep plunging V" },
|
|
436
|
+
{ id: "state-off-shoulder", label: "Off-Shoulder", dimension: "wardrobe-state", description: "Slipped off one shoulder", promptHint: "the top slipped off one shoulder, baring the skin and collarbone", term: "off-the-shoulder top" , adultOnly: true },
|
|
437
|
+
{ id: "state-halter-neck", label: "Halter Neck", dimension: "wardrobe-state", description: "Straps tied behind the neck", promptHint: "the top cut as a halter — straps tied behind the neck, shoulders and upper back bare", term: "halter neckline" , adultOnly: true },
|
|
438
|
+
{ id: "state-plunging-neck", label: "Plunging Neckline", dimension: "wardrobe-state", description: "Deep plunging V neckline", promptHint: "the neckline cut in a deep plunging V" , adultOnly: true },
|
|
420
439
|
{ id: "state-unbuttoned", label: "Unbuttoned", dimension: "wardrobe-state", description: "Open / unbuttoned", promptHint: "the outerwear hanging open and unbuttoned, falling loose at the sides", term: "worn open and unbuttoned" },
|
|
421
440
|
{ id: "state-rolled-sleeves", label: "Rolled Sleeves", dimension: "wardrobe-state", description: "Sleeves rolled up forearm", promptHint: "the sleeves rolled up to the forearm", term: "rolled-up sleeves" },
|
|
422
441
|
{ id: "state-layered", label: "Layered", dimension: "wardrobe-state", description: "Multiple stacked layers", promptHint: "the outfit composed of multiple stacked layers, each piece visible at the edges", term: "layered outfit" },
|
|
@@ -570,7 +589,7 @@ const stylingById = new Map<string, Styling>(STYLINGS.map((s) => [s.id, s]))
|
|
|
570
589
|
|
|
571
590
|
export function getStyling(id: string | undefined | null): Styling | undefined {
|
|
572
591
|
if (!id) return undefined
|
|
573
|
-
return stylingById.get(id)
|
|
592
|
+
return overlayEntry("styling", id, stylingById.get(id))
|
|
574
593
|
}
|
|
575
594
|
|
|
576
595
|
export function getStylingLabel(id: string | undefined | null, fallback?: string): string {
|
|
@@ -623,6 +642,49 @@ function collectStylingFragments(
|
|
|
623
642
|
? lipStateRaw.includes("lip-state-bold-red")
|
|
624
643
|
: lipStateRaw === "lip-state-bold-red"
|
|
625
644
|
|
|
645
|
+
// W1-a (Layer 1): the subject fold passes person + styling as ONE flat bag,
|
|
646
|
+
// so the age is readable here; drop adultOnly garments for a minor. The
|
|
647
|
+
// drop is checked on the ENTRY the collector itself resolves for that id
|
|
648
|
+
// (`getStyling`, the same lookup `fragmentFor` already reads through), not
|
|
649
|
+
// a separately-sourced id set. A separate styling node carries no age and
|
|
650
|
+
// is left alone (Layer 2 — the backend policy — covers that path).
|
|
651
|
+
const floored = isMinorAge(data as { age?: string; customAge?: number; type?: string })
|
|
652
|
+
const emit = floored ? (id: string) => (getStyling(id)?.adultOnly === true ? "" : fragmentFor(id)) : fragmentFor
|
|
653
|
+
|
|
654
|
+
// W1-b (spec 2026-09-01 §3.3): three ids say "cropped" — the Person
|
|
655
|
+
// catalog's `feature-midriff-visible` and this catalog's `top-crop-top` and
|
|
656
|
+
// `state-cropped`. Stacked they read as three separate statements about a
|
|
657
|
+
// bare stomach, which is one of the compounds the safety classifiers flag.
|
|
658
|
+
// Same mechanism and same caveat as the bold-lips dedupe above: the subject
|
|
659
|
+
// fold hands BOTH builders one flat bag, so the person pick is readable
|
|
660
|
+
// here; a separate styling node carries no `distinctiveFeature` and this
|
|
661
|
+
// no-ops. Person leads the fold order, so the person clause wins; with no
|
|
662
|
+
// person clause the garment wins over the modifier.
|
|
663
|
+
const featureRaw = (data as Record<string, unknown>).distinctiveFeature
|
|
664
|
+
const midriffPicked = Array.isArray(featureRaw)
|
|
665
|
+
? featureRaw.includes("feature-midriff-visible")
|
|
666
|
+
: featureRaw === "feature-midriff-visible"
|
|
667
|
+
// Gate on pick-minus-floor (`midriffPicked && !floored`), which equals EMISSION
|
|
668
|
+
// unless an overlay pack drops the entry — the same rule the midriff+navel fold
|
|
669
|
+
// uses (`fragmentFor(id) !== ""` inside `emitIndependentFragments`).
|
|
670
|
+
// `feature-midriff-visible` is `adultOnly`, so for a minor the person
|
|
671
|
+
// collector drops it; suppressing the styling twins as well would leave a
|
|
672
|
+
// minor who picked midriff AND crop-top with NO cropped clause at all.
|
|
673
|
+
// `floored` is the local computed just above from the same shared bag, so
|
|
674
|
+
// the two collectors cannot disagree.
|
|
675
|
+
const midriffAlready = midriffPicked && !floored
|
|
676
|
+
const cropTopPicked = (data as Record<string, unknown>).top === "top-crop-top"
|
|
677
|
+
// `top` is single-pick (always a bare string), but `wardrobeState` is
|
|
678
|
+
// declared `string | ReadonlyArray<string>` AND the subject-fold door
|
|
679
|
+
// (`normalizeSubjectFields`) unwraps a lone array pick down to a bare
|
|
680
|
+
// string — so a single "state-cropped" selection, the common case, arrives
|
|
681
|
+
// here as a plain string, not an array. The suppression must therefore be
|
|
682
|
+
// checked at BOTH emission sites below (the `typeof raw === "string"`
|
|
683
|
+
// branch and the `Array.isArray(raw)` branch), not just the array one.
|
|
684
|
+
const croppedSuppressed = (id: string): boolean =>
|
|
685
|
+
(id === "top-crop-top" && midriffAlready) ||
|
|
686
|
+
(id === "state-cropped" && (midriffAlready || cropTopPicked))
|
|
687
|
+
|
|
626
688
|
for (const dimension of STYLING_DIMENSION_ORDER) {
|
|
627
689
|
const field = STYLING_FIELD_BY_DIMENSION[dimension]
|
|
628
690
|
const raw = data[field]
|
|
@@ -630,12 +692,14 @@ function collectStylingFragments(
|
|
|
630
692
|
// jewelry / wardrobe-state / hair-state are multi-pick (string | string[]);
|
|
631
693
|
// emit each id's fragment independently and let the comma-join compose.
|
|
632
694
|
if (typeof raw === "string" && raw.length > 0) {
|
|
633
|
-
|
|
695
|
+
if (croppedSuppressed(raw)) continue
|
|
696
|
+
const fragment = emit(raw)
|
|
634
697
|
if (fragment) out.push(fragment)
|
|
635
698
|
} else if (Array.isArray(raw)) {
|
|
636
699
|
for (const item of raw) {
|
|
637
700
|
if (typeof item !== "string" || item.length === 0) continue
|
|
638
|
-
|
|
701
|
+
if (croppedSuppressed(item)) continue
|
|
702
|
+
const fragment = emit(item)
|
|
639
703
|
if (fragment) out.push(fragment)
|
|
640
704
|
}
|
|
641
705
|
}
|
package/src/subject-registry.ts
CHANGED
|
@@ -80,7 +80,7 @@ import {
|
|
|
80
80
|
} from "./styling.js"
|
|
81
81
|
import { buildHeldPropHints } from "./held-prop.js"
|
|
82
82
|
import { buildMaterialHints } from "./materials.js"
|
|
83
|
-
import {
|
|
83
|
+
import { curatedAnimalPromptHint, curatedAnimalTerm } from "./shared-catalog-overlay.js"
|
|
84
84
|
|
|
85
85
|
/** Which generation stages fold a dimension (mirrors `DirectionSurface`). */
|
|
86
86
|
export type SubjectSurface = "image" | "video" | "both"
|
|
@@ -200,7 +200,7 @@ export const SUBJECT_FIELDS = [
|
|
|
200
200
|
{ key: "styling", kind: "group", surface: "both", render: viaFragmentBuilder(buildStylingHints) },
|
|
201
201
|
{ key: "heldProp", kind: "ids", surface: "both", maxPicks: 2, render: viaListBuilder(buildHeldPropHints) },
|
|
202
202
|
{ key: "material", kind: "ids", surface: "both", maxPicks: 2, render: viaStringBuilder(buildMaterialHints) },
|
|
203
|
-
{ key: "animal", kind: "ids", surface: "both", maxPicks: 1, render: perId(
|
|
203
|
+
{ key: "animal", kind: "ids", surface: "both", maxPicks: 1, render: perId(curatedAnimalPromptHint, curatedAnimalTerm) },
|
|
204
204
|
] as const satisfies ReadonlyArray<SubjectFieldSpec>
|
|
205
205
|
|
|
206
206
|
export type SubjectFieldRow = (typeof SUBJECT_FIELDS)[number]
|
package/src/temporal.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { resolveTerm, type PickerHintMode } from "./term.js"
|
|
19
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
19
20
|
|
|
20
21
|
export type TemporalCategory = "speed" | "freeze" | "direction" | "shutter"
|
|
21
22
|
|
|
@@ -83,7 +84,7 @@ const temporalById = new Map<string, Temporal>(TEMPORALS.map((t) => [t.id, t]))
|
|
|
83
84
|
|
|
84
85
|
export function getTemporal(id: string | undefined | null): Temporal | undefined {
|
|
85
86
|
if (!id) return undefined
|
|
86
|
-
return temporalById.get(id)
|
|
87
|
+
return overlayEntry("temporal", id, temporalById.get(id))
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
export function getTemporalLabel(id: string | undefined | null, fallback?: string): string {
|
package/src/transitions.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { resolveTerm, type PickerHintMode } from "./term.js"
|
|
16
|
+
import { overlayEntry } from "./catalog-overlay.js"
|
|
16
17
|
|
|
17
18
|
export type TransitionCategory =
|
|
18
19
|
| "standard"
|
|
@@ -284,7 +285,7 @@ const transitionById = new Map<string, Transition>(
|
|
|
284
285
|
|
|
285
286
|
export function getTransition(id: string | undefined | null): Transition | undefined {
|
|
286
287
|
if (!id) return undefined
|
|
287
|
-
return transitionById.get(id)
|
|
288
|
+
return overlayEntry("transitions", id, transitionById.get(id))
|
|
288
289
|
}
|
|
289
290
|
|
|
290
291
|
export function getTransitionLabel(id: string | undefined | null, fallback?: string): string {
|