@nodaro/prompts 1.13.0 → 1.15.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.
Files changed (69) hide show
  1. package/dist/index.cjs +719 -255
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +390 -2
  4. package/dist/index.d.ts +390 -2
  5. package/dist/index.js +689 -257
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/adult-only-ratchet.test.ts +110 -0
  9. package/src/__tests__/age-floor.test.ts +347 -0
  10. package/src/__tests__/catalog-id-guard.test.ts +146 -0
  11. package/src/__tests__/catalog-overlay.test.ts +163 -0
  12. package/src/__tests__/dod-replace-pack-acceptance.test.ts +17 -0
  13. package/src/__tests__/fixtures/parameter-hint-golden.json +2 -2
  14. package/src/__tests__/minor-age-floor.test.ts +136 -0
  15. package/src/__tests__/multi-picker-spec.test.ts +21 -1
  16. package/src/__tests__/person-pack-adult-only.test.ts +45 -0
  17. package/src/__tests__/person-regional-aesthetic.test.ts +2 -1
  18. package/src/__tests__/picker-analyzer-registry.test.ts +14 -0
  19. package/src/__tests__/provider-prompt-doctrine.test.ts +39 -0
  20. package/src/action-fx.ts +2 -1
  21. package/src/aesthetic.ts +2 -1
  22. package/src/age-floor.ts +296 -0
  23. package/src/age-signal.ts +36 -0
  24. package/src/atmosphere.ts +2 -1
  25. package/src/backdrop.ts +2 -1
  26. package/src/camera-format.ts +2 -1
  27. package/src/camera-motions.ts +2 -1
  28. package/src/catalog-id-guard.ts +189 -0
  29. package/src/catalog-overlay.ts +148 -0
  30. package/src/catalog-packs.ts +27 -2
  31. package/src/character-fx.ts +2 -1
  32. package/src/color-look.ts +2 -1
  33. package/src/composition-effects.ts +2 -1
  34. package/src/era.ts +2 -1
  35. package/src/exposure-settings.ts +2 -1
  36. package/src/framing.ts +2 -1
  37. package/src/gemini-omni-inputs.ts +11 -3
  38. package/src/held-prop.ts +3 -1
  39. package/src/index.ts +4 -0
  40. package/src/instrumentation.ts +5 -4
  41. package/src/lens.ts +2 -1
  42. package/src/lighting.ts +2 -1
  43. package/src/loop-subject.ts +3 -1
  44. package/src/materials.ts +2 -1
  45. package/src/mood.ts +16 -5
  46. package/src/music-genre.ts +5 -4
  47. package/src/music-mood.ts +4 -3
  48. package/src/parameter-prompt-hint.ts +13 -57
  49. package/src/person-packs.ts +14 -2
  50. package/src/person.ts +75 -38
  51. package/src/photo-genre.ts +14 -3
  52. package/src/photographer.ts +2 -1
  53. package/src/picker-analyzer-registry.ts +46 -0
  54. package/src/picker-catalogs.ts +11 -0
  55. package/src/pose.ts +17 -6
  56. package/src/post-process-effects.ts +2 -1
  57. package/src/prompt-builder.ts +3 -3
  58. package/src/prompt-wizard-categories.ts +6 -0
  59. package/src/provider-prompt-doctrine.ts +51 -2
  60. package/src/render-quality.ts +2 -1
  61. package/src/setting.ts +3 -1
  62. package/src/shared-catalog-overlay.ts +83 -0
  63. package/src/style.ts +17 -1
  64. package/src/styling.ts +62 -30
  65. package/src/subject-registry.ts +2 -2
  66. package/src/temporal.ts +2 -1
  67. package/src/transitions.ts +2 -1
  68. package/src/voice-character.ts +6 -5
  69. package/src/voice-delivery.ts +4 -3
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod"
2
+ import { isMinorAge, getAdultOnlyIds } from "./age-floor.js"
2
3
  import {
3
4
  PEOPLE,
4
5
  PERSON_DIMENSION_ORDER,
@@ -104,6 +105,14 @@ const personCleanup: ApplyCleanup = (patch, mode) => {
104
105
  } else if ("age" in patch && patch.age !== "age-custom") {
105
106
  patch.customAge = undefined
106
107
  }
108
+ // W1-a: an analysis that returned a minor age never carries a flagged id.
109
+ if (isMinorAge(patch as { age?: string; customAge?: number; type?: string })) {
110
+ const drop = getAdultOnlyIds()
111
+ for (const [k, v] of Object.entries(patch)) {
112
+ if (typeof v === "string" && drop.has(v)) (patch as Record<string, unknown>)[k] = undefined
113
+ else if (Array.isArray(v)) (patch as Record<string, unknown>)[k] = v.filter((x) => !(typeof x === "string" && drop.has(x)))
114
+ }
115
+ }
107
116
  }
108
117
 
109
118
  export const PICKER_ANALYZER_REGISTRY = {
@@ -454,10 +463,46 @@ export interface MultiPickerAnalyzerSpec {
454
463
  readonly schema: z.ZodType<Record<string, unknown>, unknown>
455
464
  readonly toolName: string
456
465
  readonly legend: string
466
+ /** Compact bullet list of the pickers NOT wired into this spec (PICKER_TYPES
467
+ * minus `types`), keyed by picker-type key so the LLM can ATTRIBUTE a gap to
468
+ * the right picker even when it was not wired. Names + dimension labels only,
469
+ * never catalog ids. Empty string when every picker is already wired. */
470
+ readonly otherPickersLegend: string
457
471
  }
458
472
 
459
473
  const MULTI_CACHE = new Map<string, MultiPickerAnalyzerSpec>()
460
474
 
475
+ /** Title-case a picker-type key for display, e.g. "person" → "Person",
476
+ * "exposure-settings" → "Exposure Settings". */
477
+ function pickerDisplayName(type: string): string {
478
+ return type
479
+ .split("-")
480
+ .map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
481
+ .join(" ")
482
+ }
483
+
484
+ /** One compact bullet per non-wired picker so the LLM can attribute a gap to a
485
+ * picker it wasn't handed. Flat pickers show their registry `label` (one axis);
486
+ * discriminated pickers show a title-cased name plus their dimension labels.
487
+ * Never lists catalog ids. Empty string when every PICKER_TYPES member is
488
+ * wired. */
489
+ function buildOtherPickersLegend(sorted: ReadonlyArray<PickerType>): string {
490
+ const otherTypes = PICKER_TYPES.filter((t) => !sorted.includes(t))
491
+ if (otherTypes.length === 0) return ""
492
+ const lines = otherTypes.map((type) => {
493
+ const descriptor = PICKER_ANALYZER_REGISTRY[type as PickerType] as PickerAnalyzerDescriptor
494
+ if (descriptor.kind === "flat") {
495
+ return `- ${type}: ${descriptor.label}`
496
+ }
497
+ const dims = descriptor.order
498
+ .map((k) => descriptor.labels[k])
499
+ .filter(Boolean)
500
+ .join(", ")
501
+ return `- ${type}: ${pickerDisplayName(type)}${dims ? ` — ${dims}` : ""}`
502
+ })
503
+ return `Non-wired pickers — use one of these keys in a gap's \`picker\` when an attribute belongs to it:\n${lines.join("\n")}`
504
+ }
505
+
461
506
  /** Build ONE forced-tool schema spanning the given pickers (each section
462
507
  * optional so an omitted picker doesn't trigger a validation retry) plus the
463
508
  * capped `gaps` sidecar. Memoized by the sorted picker-set key. */
@@ -479,6 +524,7 @@ export function buildMultiPickerAnalyzerSpec(types: ReadonlyArray<PickerType>):
479
524
  schema: z.object(shape).strict() as unknown as MultiPickerAnalyzerSpec["schema"],
480
525
  toolName: "emit_pickers",
481
526
  legend: legendParts.join("\n\n"),
527
+ otherPickersLegend: buildOtherPickersLegend(sorted),
482
528
  }
483
529
  MULTI_CACHE.set(key, result)
484
530
  return result
@@ -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 with a subtle playful expression", term: "biting the lower lip" },
108
+ { id: "biting-lip", label: "Biting Lip", category: "action", description: "Slight playful lip-bite", promptHint: "biting the lower lip with a subtle playful expression", term: "biting the 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 {
@@ -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()
@@ -256,6 +256,9 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
256
256
  "ltx-2.3-pro": "Lightricks LTX 2.3 Pro — text/image/audio→video, 6–10s, up to 4K",
257
257
  "ltx-2.3-fast": "Lightricks LTX 2.3 Fast — text/image→video, 6–20s, up to 4K",
258
258
  "gemini-omni-video": "Google Gemini Omni — multimodal video with native audio, 4–10s, up to 4K.",
259
+ "gemini-omni-flash": "Google Gemini Omni Flash — faster, cheaper Omni tier; multimodal video with native audio, 4–10s, up to 4K.",
260
+ "wan-3": "Wan 3.0 — multimodal refs (10 images / 5 videos / 5 audio) or first+last frame, native audio, 2–30s, 480p/720p/1080p",
261
+ "wan-3-prime": "Wan 3.0 Prime — high-speed Wan 3.0 tier; same surface, faster turnaround at a higher rate",
259
262
  "grok-imagine-video-1.5": "Grok Imagine 1.5 — image-to-video only; requires an input image",
260
263
  },
261
264
  "image-to-video": {
@@ -289,6 +292,9 @@ export const PROVIDER_CAPABILITIES: Record<string, Record<string, string>> = {
289
292
  "ltx-2.3-pro": "Lightricks LTX 2.3 Pro — start/end frame i2v + audio→video, 6–10s, up to 4K",
290
293
  "ltx-2.3-fast": "Lightricks LTX 2.3 Fast — start/end frame i2v, 6–20s, up to 4K",
291
294
  "gemini-omni-video": "Google Gemini Omni — multimodal video with native audio, 4–10s, up to 4K.",
295
+ "gemini-omni-flash": "Google Gemini Omni Flash — faster, cheaper Omni tier; multimodal video with native audio, 4–10s, up to 4K.",
296
+ "wan-3": "Wan 3.0 — multimodal refs (10 images / 5 videos / 5 audio) or first+last frame, native audio, 2–30s, 480p/720p/1080p",
297
+ "wan-3-prime": "Wan 3.0 Prime — high-speed Wan 3.0 tier; same surface, faster turnaround at a higher rate",
292
298
  "grok-imagine-video-1.5": "Grok Imagine 1.5 — stylized animation, 1–15s, 480p/720p (image required)",
293
299
  },
294
300
  "video-to-video": {
@@ -239,8 +239,8 @@ KIE VEO API docs (docs.kie.ai/veo3-api/generate-veo-3-video). Captured 2026-08-0
239
239
  }
240
240
 
241
241
  const GEMINI_OMNI_DOCTRINE: ProviderPromptDoctrine = {
242
- providers: ["gemini-omni-video"],
243
- heading: "Gemini Omni Video (gemini-omni-video)",
242
+ providers: ["gemini-omni-video", "gemini-omni-flash"],
243
+ heading: "Gemini Omni (gemini-omni-video, gemini-omni-flash)",
244
244
  tips: [
245
245
  "Multimodal Google video with native audio: text-to-video, image-to-video, and video-edit through the same prompt surface. 4/6/8/10s; 720p/1080p or 4K tier.",
246
246
  "Structure like the platform default: subject → action → scene → lighting → camera → style. Quote dialogue lines to have them spoken; describe SFX/ambience plainly in the prompt.",
@@ -262,6 +262,7 @@ subject → action → scene/environment → lighting → camera movement → st
262
262
 
263
263
  **Duration & tiers**
264
264
  - 4 / 6 / 8 / 10 seconds. 720p/1080p tier or the pricier 4K tier — pick 4K only when the deliverable needs it (nearly 2× the credits).
265
+ - gemini-omni-flash is the faster/cheaper tier with the identical request surface — same 4/6/8/10s, same 720p/1080p and 4K tiers, same video-edit path. Everything above applies verbatim.
265
266
 
266
267
  Source: KIE gemini-omni-video market contract (parameters + live behavior probed for the
267
268
  aspect-ratio hard-reject, see providers/kie/video.ts). Captured 2026-08-09.`,
@@ -334,6 +335,53 @@ Source: Alibaba Cloud Model Studio — "Text-to-video / image-to-video prompt gu
334
335
  (alibabacloud.com/help/en/model-studio/text-to-video-prompt). Captured 2026-08-09.`,
335
336
  }
336
337
 
338
+ // Wan 3.0 is a SEPARATE doctrine from WAN_DOCTRINE on purpose: Wan 2.x binds
339
+ // references as "Image 1" / "Video 1" (capitalised, WITH a space) while the Wan
340
+ // 3.0 contract uses "Image1" / "Video1" / "Audio1" (no space), and 3.0's surface
341
+ // (adaptive aspect, boolean audio, 30s, mutually-exclusive frame vs reference
342
+ // modes) is different. Folding them together would ship a token format the model
343
+ // does not use. There is no published Wan 3.0 prompt guide, so the body below is
344
+ // KIE contract facts only — no invented vendor style claims.
345
+ const WAN_3_DOCTRINE: ProviderPromptDoctrine = {
346
+ providers: ["wan-3", "wan-3-prime"],
347
+ heading: "Wan 3.0 (wan-3, wan-3-prime)",
348
+ tips: [
349
+ "Two INPUT MODES, exclusive on the wire: first/last frame, OR reference mode (images + videos + audio). With any reference wired the platform folds the frame into the references and names it in the prompt.",
350
+ "References bind by ordinal token in array order: Image1, Image2, Video1, Audio1 — no space, unlike Wan 2.x's \"Image 1\". Name every wired asset or it may be ignored.",
351
+ "Reference caps: 10 images / 5 videos / 5 audio clips; each video and each audio clip 1-15s, with ≤15s combined per array. With reference videos, input seconds + output duration ≤ 30.",
352
+ "2-30 seconds (default 5); 480p/720p/1080p; aspect adaptive (default, matches the input media) or 16:9 / 4:3 / 1:1 / 3:4 / 9:16. Prompt cap 20,000 chars — excess is truncated silently.",
353
+ "`audio` is a boolean, ON by default: the clip comes back with an ambient/SFX track. Cue the sound you want in the prompt, or state the exclusion (\"no music\") — it is not a dialogue guarantee.",
354
+ "wan-3-prime is the HIGH-SPEED tier: identical surface and limits, faster turnaround at a higher per-second rate. It is not a quality upgrade — choose it for latency, not for looks.",
355
+ ],
356
+ doctrine: `Prompt structure (no public Wan 3.0 prompt guide exists — the KIE API contract is the
357
+ doctrine source, like MiniMax H3 and HappyHorse; platform-standard structure applies):
358
+ subject → action → scene/environment → lighting → camera movement → style → constraints.
359
+
360
+ **Modes (mutually exclusive at the provider)**
361
+ - Frame mode: first_frame_url, optionally with last_frame_url, and NO references — the frames anchor the shot exactly, so describe MOTION and camera, not the still.
362
+ - Reference mode: image / video / audio reference arrays. The provider CANNOT take these together with the first/last frame parameters, so when both are wired the platform folds — the frame is appended to the reference images (after the caller's own, ordinals unchanged) and bound in the prompt as the opening/closing frame. Write for reference mode whenever a reference is attached.
363
+ - Text-only runs are supported and are the model's default mode.
364
+
365
+ **Reference binding**
366
+ - Assets bind by ORDINAL TOKEN in array order: Image1, Image2, …, Video1, …, Audio1, …. Note the format has NO space — Wan 2.x's "Image 1" is a different generation and does not apply here.
367
+ - Write the binding into the prompt explicitly ("Image1 walks into the room described in Image2"); an unnamed reference may simply be ignored.
368
+ - Caps: up to 10 images, 5 videos, 5 audio clips. Each video and each audio clip must be 1-15s with ≤15s combined per array. Audio should not be the only media input — pair it with an image or a video.
369
+
370
+ **Duration, resolution, aspect**
371
+ - 2-30 seconds (provider default 5). With reference videos there is an extra ceiling: input video duration + output duration ≤ 30 seconds.
372
+ - 480p / 720p / 1080p. Aspect "adaptive" (the default — the model selects the ratio from the input media and intent) or 16:9 / 4:3 / 1:1 / 3:4 / 9:16. There is no 21:9.
373
+ - Prompts accept Chinese and English, up to 20,000 characters; anything beyond is truncated silently, so front-load the load-bearing content.
374
+
375
+ **Audio**
376
+ - The "audio" boolean defaults ON and produces an ambient/SFX track with the clip. Describe the soundscape you want plainly ("rain on glass, distant traffic"), or state the exclusion, or turn the toggle off. The contract documents no lip-synced dialogue guarantee — plan spoken lines as a separate TTS + lip-sync pass.
377
+
378
+ **Tiers**
379
+ - wan-3 and wan-3-prime take identical inputs. Prime trades a higher per-second rate for faster turnaround; it is not documented as a quality tier.
380
+
381
+ Source: KIE Wan 3.0 market contract (docs.kie.ai/market/wan/3-0-video,
382
+ docs.kie.ai/market/wan/3-0-video-prime). Captured 2026-09-01.`,
383
+ }
384
+
337
385
  const HAPPYHORSE_DOCTRINE: ProviderPromptDoctrine = {
338
386
  providers: ["happyhorse", "happyhorse-i2v", "happyhorse-ref2v", "happyhorse-edit"],
339
387
  heading: "HappyHorse 1.1 (happyhorse, happyhorse-i2v, happyhorse-ref2v)",
@@ -391,6 +439,7 @@ export const PROVIDER_PROMPT_DOCTRINES: readonly ProviderPromptDoctrine[] = [
391
439
  GEMINI_OMNI_DOCTRINE,
392
440
  GROK_IMAGINE_DOCTRINE,
393
441
  WAN_DOCTRINE,
442
+ WAN_3_DOCTRINE,
394
443
  HAPPYHORSE_DOCTRINE,
395
444
  RUNWAY_KIE_DOCTRINE,
396
445
  ]
@@ -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
 
@@ -85,6 +86,7 @@ export const SETTINGS: ReadonlyArray<Setting> = [
85
86
  { id: "parking-lot", label: "Parking Lot", category: "urban", description: "Suburban parking lot at dusk", promptHint: "set in an empty suburban parking lot at dusk with sodium-vapor lamps casting orange pools, scattered shopping carts and painted lane lines" },
86
87
  { id: "penthouse", label: "Penthouse", category: "urban", description: "Luxury penthouse with skyline view", promptHint: "set in a luxury penthouse interior with panoramic skyline views, marble floors, modernist furniture and low warm ambient light" },
87
88
  { id: "gas-station", label: "Gas Station", category: "urban", description: "Lonely highway gas station at night", promptHint: "set at a lonely highway gas station at night with a fluorescent canopy, bug-swarmed sodium lamps and cracked asphalt" },
89
+ { id: "open-air-market", label: "Open-Air Market", category: "urban", description: "Bustling market of vendor stalls under canopies", term: "open-air market", promptHint: "set in a bustling open-air market — rows of vendor stalls under thatched and canvas canopies, produce piled high, warm dusty light and crowds moving between the stalls" },
88
90
 
89
91
  // -------------------- Nature --------------------
90
92
  { id: "forest", label: "Forest Clearing", category: "nature", description: "Sunlit mossy clearing", promptHint: "set in a sunlit forest clearing with moss-covered stones, dappled light through tall trees and a soft carpet of fallen leaves" },
@@ -117,7 +119,7 @@ const settingById = new Map<string, Setting>(SETTINGS.map((s) => [s.id, s]))
117
119
 
118
120
  export function getSetting(id: string | undefined | null): Setting | undefined {
119
121
  if (!id) return undefined
120
- return settingById.get(id)
122
+ return overlayEntry("setting", id, settingById.get(id))
121
123
  }
122
124
 
123
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
@@ -89,13 +90,28 @@ export const STYLES: ReadonlyArray<Style> = [
89
90
  { id: "acrylic-paint", label: "Acrylic Paint", description: "Fast-drying opaque acrylic on canvas", promptHint: "rendered as an acrylic painting on canvas, fast-drying opaque pigment with crisp sharp edges, confident quick brush strokes, high-key saturated color and a flatter more graphic finish than traditional oil paint", term: "acrylic painting" },
90
91
  { id: "mixed-media", label: "Mixed Media", description: "Collage + paint + ink hybrid", promptHint: "rendered as a mixed-media artwork combining torn paper collage, acrylic paint, ink and graphite on a layered substrate, heterogeneous textures, visible tape and stitching, and an exuberant hand-assembled studio-art quality" },
91
92
  { id: "manga", label: "Manga", description: "Inked B&W Japanese comic panel", promptHint: "rendered as inked manga panel art, crisp black ink on white with confident line weight variation, screen-tone dot patterns for shading, dramatic speed lines and the distinctly Japanese black-and-white comic aesthetic — separate from full-color anime" },
93
+ { id: "early-color-photo", label: "Early Color Photo", description: "Prokudin-Gorsky / autochrome early-1900s color", promptHint: "rendered as an early-1900s color photograph in the Prokudin-Gorsky / autochrome tradition — soft three-colour-separation registration, muted dye-toned palette, fine grain and a gentle antique warmth", term: "early autochrome color photograph" },
92
94
  ] as const
93
95
 
94
96
  const styleById = new Map<string, Style>(STYLES.map((s) => [s.id, s]))
95
97
 
96
98
  export function getStyle(id: string | undefined | null): Style | undefined {
97
99
  if (!id) return undefined
98
- 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
99
115
  }
100
116
 
101
117
  export function getStyleLabel(id: string | undefined | null, fallback?: string): string {