@nodaro/prompts 1.6.0 → 1.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.6.0",
3
+ "version": "1.7.2",
4
4
  "description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { MODEL_CATALOG } from "@nodaro/shared"
3
+ import { getPromptDoctrine } from "../provider-prompt-doctrine.js"
4
+
5
+ /**
6
+ * The badge's truthfulness guarantee (Cine build-brief §7 — never overclaim):
7
+ * every video GENERATION model is either doctrine-covered or DELIBERATELY
8
+ * generic. A new video model that is neither fails this test — forcing the
9
+ * author to write a sourced doctrine or consciously add it to the generic
10
+ * set with a reason.
11
+ */
12
+
13
+ /** Models deliberately left WITHOUT a doctrine, with the reason. */
14
+ const DELIBERATELY_GENERIC: ReadonlyMap<string, string> = new Map([
15
+ // Older ByteDance engine (pre-Seedance-2 surface: single image, no refs/audio
16
+ // levers) — mapping the Seedance family doctrine would overclaim.
17
+ ["bytedance-lite", "older engine, different surface"],
18
+ ["bytedance-pro", "older engine, different surface"],
19
+ ["bytedance-pro-fast", "older engine, different surface"],
20
+ // Hailuo pre-H3 tiers: single-image i2v without the multimodal reference
21
+ // surface the H3 doctrine teaches.
22
+ ["hailuo-2.3", "pre-H3 tier without the multimodal surface"],
23
+ ["hailuo-2.3-pro", "pre-H3 tier without the multimodal surface"],
24
+ ["hailuo-standard", "pre-H3 tier without the multimodal surface"],
25
+ // Legacy/simple tiers with no vendor guidance beyond the platform default.
26
+ ["minimax", "legacy 5s tier, no vendor guide"],
27
+ ["seedance", "legacy Seedance 1.x, superseded"],
28
+ ["ltx-2.3-fast", "no vendor prompt guide published"],
29
+ ["sora2", "roster utility — no first-party guide via KIE"],
30
+ ["sora2-pro", "roster utility — no first-party guide via KIE"],
31
+ ])
32
+
33
+ /** Utility modes that are driven by inputs, not prose prompting — doctrine
34
+ * coverage isn't meaningful for them (spec excludes them from the roster). */
35
+ const UTILITY_ONLY_MODES = new Set(["extend", "motion-transfer", "lip-sync", "video-upscale", "v2v", "video-analysis", "video-audit"])
36
+
37
+ describe("doctrine roster completeness", () => {
38
+ const videoGenerationIds = Object.values(MODEL_CATALOG)
39
+ .filter((m) => m.kind === "video")
40
+ .filter((m) => m.modes?.some((mode) => (mode === "t2v" || mode === "i2v") && !UTILITY_ONLY_MODES.has(mode)))
41
+ .map((m) => m.id)
42
+
43
+ it("covers every video generation model — or lists it as deliberately generic", () => {
44
+ const uncovered = videoGenerationIds.filter(
45
+ (id) => getPromptDoctrine(id) === undefined && !DELIBERATELY_GENERIC.has(id),
46
+ )
47
+ expect(
48
+ uncovered,
49
+ `New video model(s) with neither a doctrine nor a deliberate-generic entry: ${uncovered.join(", ")}. ` +
50
+ "Write a sourced doctrine or add to DELIBERATELY_GENERIC with a reason.",
51
+ ).toEqual([])
52
+ })
53
+
54
+ it("the deliberate-generic set stays honest (no entry that is actually covered)", () => {
55
+ for (const id of DELIBERATELY_GENERIC.keys()) {
56
+ expect(getPromptDoctrine(id), `${id} is covered — remove it from DELIBERATELY_GENERIC`).toBeUndefined()
57
+ }
58
+ })
59
+ })
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest"
2
+
3
+ import { resolveGeminiOmniI2vInputs } from "../gemini-omni-inputs.js"
4
+
5
+ /**
6
+ * Gemini Omni i2v input resolution — the flat `image_urls` sibling of the
7
+ * seedance-2 resolver. The stakes: an unbound image list reads as loose
8
+ * context to a multimodal model (field finding 2026-08-14 — identity refs
9
+ * rode every keyframes call and the cast still drifted), and an unbudgeted
10
+ * list trips KIE's 7-input hard reject.
11
+ */
12
+ describe("resolveGeminiOmniI2vInputs", () => {
13
+ const FIRST = "https://r2/anchor.png"
14
+ const refs = (n: number) => Array.from({ length: n }, (_, i) => `https://r2/ref-${i + 1}.png`)
15
+
16
+ it("binds the roles: image 1 is the opening frame, the rest are identities — not frames", () => {
17
+ const r = resolveGeminiOmniI2vInputs({ prompt: "a walk on the beach", firstFrameUrl: FIRST, refImageUrls: refs(3) })
18
+ expect(r.imageUrls).toEqual([FIRST, ...refs(3)])
19
+ expect(r.promptSuffix).toBe(
20
+ "Use @image_1 as the opening (first) frame of the video. " +
21
+ "@image_2 through @image_4 are identity references for this shot's subjects — match each subject's exact appearance; they are not frames.",
22
+ )
23
+ expect(r.droppedRefImages).toBe(0)
24
+ })
25
+
26
+ it("a single reference gets the singular sentence", () => {
27
+ const r = resolveGeminiOmniI2vInputs({ firstFrameUrl: FIRST, refImageUrls: refs(1) })
28
+ expect(r.promptSuffix).toContain("@image_2 is an identity reference")
29
+ expect(r.promptSuffix).not.toContain("through")
30
+ })
31
+
32
+ it("no references ⇒ byte-identical plain i2v: single image, no suffix", () => {
33
+ const r = resolveGeminiOmniI2vInputs({ prompt: "p", firstFrameUrl: FIRST })
34
+ expect(r).toEqual({ imageUrls: [FIRST], promptSuffix: "", droppedRefImages: 0 })
35
+ })
36
+
37
+ it("drops TRAILING references to fit the 7-input quota — the start frame is never the one that goes", () => {
38
+ const r = resolveGeminiOmniI2vInputs({ firstFrameUrl: FIRST, refImageUrls: refs(9) })
39
+ expect(r.imageUrls).toHaveLength(7)
40
+ expect(r.imageUrls[0]).toBe(FIRST)
41
+ expect(r.imageUrls.at(-1)).toBe("https://r2/ref-6.png")
42
+ expect(r.droppedRefImages).toBe(3)
43
+ // The binding names exactly the kept span.
44
+ expect(r.promptSuffix).toContain("@image_2 through @image_7")
45
+ })
46
+
47
+ it("a connected source video eats two slots (images + 2×videos ≤ 7)", () => {
48
+ const r = resolveGeminiOmniI2vInputs({ firstFrameUrl: FIRST, refImageUrls: refs(9), videoConnected: true })
49
+ expect(r.imageUrls).toHaveLength(5)
50
+ expect(r.droppedRefImages).toBe(5)
51
+ })
52
+
53
+ it("suppresses the opening-frame sentence when the prompt already binds it, keeping the identity sentence", () => {
54
+ const r = resolveGeminiOmniI2vInputs({
55
+ prompt: "use @image_1 as the first frame, it is the last keyframe of @video_1",
56
+ firstFrameUrl: FIRST,
57
+ refImageUrls: refs(2),
58
+ })
59
+ expect(r.promptSuffix).not.toContain("opening (first) frame")
60
+ expect(r.promptSuffix).toContain("identity references")
61
+ })
62
+
63
+ it("skips empty/undefined reference entries without burning slots", () => {
64
+ const r = resolveGeminiOmniI2vInputs({ firstFrameUrl: FIRST, refImageUrls: [undefined, "", ...refs(2)] })
65
+ expect(r.imageUrls).toEqual([FIRST, ...refs(2)])
66
+ expect(r.droppedRefImages).toBe(0)
67
+ })
68
+ })
@@ -77,7 +77,17 @@ describe("registry membership (Task 2: person only)", () => {
77
77
  describe("registry invariants (all analyzable pickers)", () => {
78
78
  it("registers the batch", () => {
79
79
  expect(new Set(PICKER_TYPES)).toEqual(
80
- new Set(["person", "styling", "framing", "lens", "camera-format"]),
80
+ new Set([
81
+ // multi-dim (discriminated)
82
+ "person", "styling", "framing", "lighting", "temporal", "exposure-settings",
83
+ "music-genre", "music-mood", "instrumentation", "voice-character", "voice-delivery",
84
+ // single-value (flat)
85
+ "lens", "camera-format", "setting", "atmosphere", "style", "mood", "color-look",
86
+ "photographer", "aesthetic", "era", "photo-genre", "backdrop", "render-quality",
87
+ "composition-effects", "post-process-effects", "action-fx", "loop-subject",
88
+ "transition", "character-fx", "pose", "material", "held-prop", "camera-motion",
89
+ "animal", "vehicle", "weapon", "furniture",
90
+ ]),
81
91
  )
82
92
  })
83
93
  it.each(PICKER_TYPES)("%s: every dimension enum equals its catalog ids and is non-empty", (t) => {
@@ -13,7 +13,7 @@ describe("PROVIDER_PROMPT_DOCTRINES", () => {
13
13
  expect(d.providers.length).toBeGreaterThan(0)
14
14
  for (const p of d.providers) expect(MODEL_CATALOG[p]).toBeDefined()
15
15
  expect(d.tips.length).toBeGreaterThanOrEqual(3)
16
- expect(d.tips.length).toBeLessThanOrEqual(6)
16
+ expect(d.tips.length).toBeLessThanOrEqual(7)
17
17
  for (const t of d.tips) expect(t.length).toBeLessThanOrEqual(220)
18
18
  expect(d.doctrine.length).toBeGreaterThan(500)
19
19
  expect(d.heading.length).toBeGreaterThan(0)
@@ -23,9 +23,11 @@ describe("PROVIDER_PROMPT_DOCTRINES", () => {
23
23
  it("resolves by provider id, returns undefined/[] for providers without doctrine", () => {
24
24
  expect(getPromptDoctrine("seedance-2")).toBeDefined()
25
25
  expect(getPromptDoctrine("seedance-2-fast")).toBeDefined()
26
- expect(getPromptDoctrine("veo3.1")).toBeUndefined()
26
+ // bytedance-lite/pro + hailuo-2.3 are DELIBERATELY uncovered (older engines
27
+ // whose surfaces differ — mapping the family doctrine would overclaim).
28
+ expect(getPromptDoctrine("bytedance-lite")).toBeUndefined()
27
29
  expect(getPromptTips("seedance-2").length).toBeGreaterThan(0)
28
- expect(getPromptTips("veo3.1")).toEqual([])
30
+ expect(getPromptTips("bytedance-lite")).toEqual([])
29
31
  })
30
32
 
31
33
  it("seedance doctrine encodes the official rules and bans the unstable patterns", () => {
@@ -193,3 +193,51 @@ describe("promptBindsFirstFrame suffix suppression (overlap colon-position findi
193
193
  expect(both.promptSuffix).toContain("closing (last) frame") // pair sentence kept — last frame has no in-prompt binding
194
194
  })
195
195
  })
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Per-provider limits (2026-08-15): Seedance 2.5 carries the same three input
199
+ // kinds with much wider caps (30 / 10 / 10). The resolver takes the limits as
200
+ // an argument — defaulting to the 2.0 caps so every existing caller is
201
+ // byte-identical — and the adapter passes the provider's own.
202
+ // ---------------------------------------------------------------------------
203
+
204
+ describe("resolveSeedance2Inputs — per-provider limits", () => {
205
+ const WIDE = { images: 30, videos: 10, audio: 10 }
206
+ const refs = (n: number) => Array.from({ length: n }, (_, i) => `https://r2/ref-${i + 1}.png`)
207
+
208
+ it("keeps 12 reference images + both frames under the 2.5 caps (the 2.0 default would drop 5)", () => {
209
+ const r = resolveSeedance2Inputs({
210
+ firstFrameUrl: "https://r2/first.png",
211
+ lastFrameUrl: "https://r2/last.png",
212
+ refImageUrls: refs(12),
213
+ limits: WIDE,
214
+ })
215
+ expect(r.mode).toBe("reference")
216
+ expect(r.referenceImageUrls).toHaveLength(14)
217
+ expect(r.droppedRefImages).toBe(0)
218
+ // Frames still ride LAST, ordinals bound to their true positions.
219
+ expect(r.promptSuffix).toContain("@image_13")
220
+ expect(r.promptSuffix).toContain("@image_14")
221
+ })
222
+
223
+ it("videos and audio slice to the provided caps", () => {
224
+ const r = resolveSeedance2Inputs({
225
+ refImageUrls: refs(1),
226
+ refVideoUrls: Array.from({ length: 12 }, (_, i) => `https://r2/v${i}.mp4`),
227
+ refAudioUrls: Array.from({ length: 12 }, (_, i) => `https://r2/a${i}.mp3`),
228
+ limits: WIDE,
229
+ })
230
+ expect(r.referenceVideoUrls).toHaveLength(10)
231
+ expect(r.referenceAudioUrls).toHaveLength(10)
232
+ })
233
+
234
+ it("omitting limits keeps the 2.0 caps byte-identical (9-slot drop-trailing)", () => {
235
+ const r = resolveSeedance2Inputs({
236
+ firstFrameUrl: "https://r2/first.png",
237
+ lastFrameUrl: "https://r2/last.png",
238
+ refImageUrls: refs(12),
239
+ })
240
+ expect(r.referenceImageUrls).toHaveLength(9)
241
+ expect(r.droppedRefImages).toBe(5)
242
+ })
243
+ })
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from "vitest"
2
+
3
+ import { resolveVeoI2vInputs } from "../veo-i2v-inputs.js"
4
+
5
+ /**
6
+ * VEO 3.x i2v input resolution. VEO's API makes frame conditioning and
7
+ * reference ingredients mutually exclusive (one imageUrls array, ≤3, whose
8
+ * meaning flips with generationType) — so an anchored call that must carry
9
+ * identity references moves to REFERENCE_2_VIDEO with the anchor in seat 1.
10
+ * References win the seats (the 2026-08-14 standing rule: refs are a must,
11
+ * frames additional): the end anchor is dropped in reference mode.
12
+ */
13
+ describe("resolveVeoI2vInputs", () => {
14
+ const FIRST = "https://r2/anchor.png"
15
+ const refs = (n: number) => Array.from({ length: n }, (_, i) => `https://r2/ref-${i + 1}.png`)
16
+
17
+ it("no references ⇒ plain frame mode, byte-identical: frames kept, no generationType, no suffix", () => {
18
+ const r = resolveVeoI2vInputs({ prompt: "p", firstFrameUrl: FIRST, endFrameUrl: "https://r2/end.png" })
19
+ expect(r).toEqual({
20
+ imageUrls: [FIRST, "https://r2/end.png"],
21
+ promptSuffix: "",
22
+ droppedRefImages: 0,
23
+ droppedEndFrame: false,
24
+ })
25
+ })
26
+
27
+ it("references flip the call to REFERENCE_2_VIDEO with the anchor in seat 1, capped at 3", () => {
28
+ const r = resolveVeoI2vInputs({ prompt: "p", firstFrameUrl: FIRST, refImageUrls: refs(4) })
29
+ expect(r.generationType).toBe("REFERENCE_2_VIDEO")
30
+ expect(r.imageUrls).toEqual([FIRST, "https://r2/ref-1.png", "https://r2/ref-2.png"])
31
+ expect(r.droppedRefImages).toBe(2)
32
+ expect(r.promptSuffix).toBe(
33
+ "Use @image_1 as the opening (first) frame of the video. " +
34
+ "@image_2 through @image_3 are identity references for this shot's subjects — match each subject's exact appearance; they are not frames.",
35
+ )
36
+ })
37
+
38
+ it("the end anchor is DROPPED in reference mode — references win the seats", () => {
39
+ const r = resolveVeoI2vInputs({ firstFrameUrl: FIRST, endFrameUrl: "https://r2/end.png", refImageUrls: refs(2) })
40
+ expect(r.imageUrls).toEqual([FIRST, "https://r2/ref-1.png", "https://r2/ref-2.png"])
41
+ expect(r.droppedEndFrame).toBe(true)
42
+ })
43
+
44
+ it("a single kept reference gets the singular sentence", () => {
45
+ const r = resolveVeoI2vInputs({ firstFrameUrl: FIRST, refImageUrls: refs(1) })
46
+ expect(r.promptSuffix).toContain("@image_2 is an identity reference")
47
+ })
48
+
49
+ it("suppresses the opening-frame sentence when the prompt already binds it", () => {
50
+ const r = resolveVeoI2vInputs({
51
+ prompt: "use @image_1 as the first frame, it is the last keyframe of @video_1",
52
+ firstFrameUrl: FIRST,
53
+ refImageUrls: refs(1),
54
+ })
55
+ expect(r.promptSuffix).not.toContain("opening (first) frame")
56
+ expect(r.promptSuffix).toContain("identity reference")
57
+ })
58
+ })
@@ -0,0 +1,73 @@
1
+ import { VIDEO_REF_LIMITS_BY_PROVIDER } from "@nodaro/shared"
2
+
3
+ import { promptBindsFirstFrame } from "./seedance-2-inputs.js"
4
+ import { identityRefsSentence, REF_BINDING } from "./video-reference-resolver.js"
5
+
6
+ /**
7
+ * Gemini Omni Video i2v input resolution — the sibling of
8
+ * `resolveSeedance2Inputs` for a model whose multimodal channel is ONE flat
9
+ * `image_urls` list.
10
+ *
11
+ * WHY BINDING IS LOAD-BEARING: Gemini Omni receives the start frame and the
12
+ * identity references in the same array, with nothing in the payload marking
13
+ * which is which. A multimodal model treats unbound images as loose context —
14
+ * field finding (recast keyframes run, 2026-08-14): the identity references
15
+ * rode every call and the cast still drifted part to part, because the prompt
16
+ * never said the images WERE identities to keep. So the resolver names the
17
+ * roles in a prompt suffix, through the same `REF_BINDING` swap-point every
18
+ * other video binding uses: image 1 is the opening frame; the rest are
19
+ * identity references, explicitly not frames.
20
+ *
21
+ * BUDGETED, NEVER REJECTED, for the list this resolver assembles: KIE's quota
22
+ * is `images + 2×videos ≤ 7`, and `runGeminiOmni` hard-rejects overflow. That
23
+ * reject is right for a caller-assembled list (the user's own images should
24
+ * not silently thin out) and wrong for THIS merge, where the overflow is our
25
+ * own construction — so trailing references are dropped to fit, the start
26
+ * frame always kept, mirroring `resolveSeedance2Inputs`' drop-trailing
27
+ * convention, and the drop count is reported for the caller to log.
28
+ *
29
+ * BYTE-IDENTICAL when there is nothing to bind: no references ⇒ no suffix and
30
+ * a single-image list — exactly what every plain gemini-omni i2v call has
31
+ * always sent.
32
+ */
33
+
34
+ export interface GeminiOmniI2vInputsArgs {
35
+ /** The composed prompt, used only to detect an existing first-frame binding. */
36
+ prompt?: string
37
+ /** The start frame — always kept, always first in the list. */
38
+ firstFrameUrl: string
39
+ /** Identity references, in priority order (trailing ones drop first). */
40
+ refImageUrls?: Array<string | undefined>
41
+ /** A connected source video occupies 2 of the 7 input slots (KIE quota). */
42
+ videoConnected?: boolean
43
+ }
44
+
45
+ export interface GeminiOmniI2vInputsResult {
46
+ /** `[firstFrameUrl, ...keptRefs]` — the `image_urls` payload, quota-fitted. */
47
+ imageUrls: string[]
48
+ /** The role-binding sentences; empty when no reference survived the budget. */
49
+ promptSuffix: string
50
+ /** References dropped to fit the quota — surface in a log, never silently. */
51
+ droppedRefImages: number
52
+ }
53
+
54
+ /** The catalog-declared cap (7) — read from the shared limits map so the
55
+ * wire-contract number has one home; the literal is only the safety net. */
56
+ const GEMINI_OMNI_INPUT_SLOTS = VIDEO_REF_LIMITS_BY_PROVIDER["gemini-omni-video"]?.images ?? 7
57
+
58
+ export function resolveGeminiOmniI2vInputs(args: GeminiOmniI2vInputsArgs): GeminiOmniI2vInputsResult {
59
+ const refs = (args.refImageUrls ?? []).filter((u): u is string => typeof u === "string" && u.length > 0)
60
+ const slots = GEMINI_OMNI_INPUT_SLOTS - (args.videoConnected ? 2 : 0)
61
+ const refSlots = Math.max(0, slots - 1)
62
+ const kept = refs.slice(0, refSlots)
63
+ const droppedRefImages = refs.length - kept.length
64
+ const imageUrls = [args.firstFrameUrl, ...kept]
65
+ if (kept.length === 0) return { imageUrls, promptSuffix: "", droppedRefImages }
66
+
67
+ // The opening-frame sentence is suppressed when the prompt already binds it
68
+ // at its own (working) position — same field-finding rule as seedance-2: a
69
+ // duplicate directive at the end dilutes the one that works.
70
+ const frameSentence = promptBindsFirstFrame(args.prompt) ? "" : REF_BINDING.frame(1, "opening")
71
+ const promptSuffix = [frameSentence, identityRefsSentence(2, kept.length + 1)].filter(Boolean).join(" ")
72
+ return { imageUrls, promptSuffix, droppedRefImages }
73
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,8 @@ export * from "./sound-aggregator.js"
20
20
  export * from "./assemble-suno-input.js"
21
21
  export * from "./assemble-image-input.js"
22
22
  export * from "./seedance-2-inputs.js"
23
+ export * from "./gemini-omni-inputs.js"
24
+ export * from "./veo-i2v-inputs.js"
23
25
  export * from "./person.js"
24
26
  export * from "./picker-catalogs.js"
25
27
  export * from "./picker-analyzer-registry.js"
@@ -65,3 +67,4 @@ export * from "./factory-presets.js"
65
67
  export * from "./style-presets.js"
66
68
  export * from "./object-asset-presets.js"
67
69
  export * from "./factory-snippets/index.js"
70
+ export * from "./picker-wiring.js"
@@ -10,6 +10,36 @@ import { STYLINGS, STYLING_DIMENSION_ORDER, STYLING_DIMENSION_LABELS, STYLING_FI
10
10
  import { FRAMINGS, FRAMING_CATEGORY_ORDER, FRAMING_CATEGORY_LABELS, FRAMING_FIELD_BY_CATEGORY, getFramingCategoryLimit } from "./framing.js"
11
11
  import { LENSES } from "./lens.js"
12
12
  import { CAMERA_FORMATS } from "./camera-format.js"
13
+ import { ANIMALS, VEHICLES, WEAPONS, FURNITURE } from "@nodaro/shared"
14
+ import { SETTINGS } from "./setting.js"
15
+ import { ATMOSPHERES } from "./atmosphere.js"
16
+ import { STYLES } from "./style.js"
17
+ import { MOODS } from "./mood.js"
18
+ import { COLOR_LOOKS } from "./color-look.js"
19
+ import { PHOTOGRAPHERS } from "./photographer.js"
20
+ import { AESTHETICS } from "./aesthetic.js"
21
+ import { ERAS } from "./era.js"
22
+ import { PHOTO_GENRES } from "./photo-genre.js"
23
+ import { BACKDROPS } from "./backdrop.js"
24
+ import { RENDER_QUALITIES } from "./render-quality.js"
25
+ import { COMPOSITION_EFFECTS } from "./composition-effects.js"
26
+ import { POST_PROCESS_EFFECTS } from "./post-process-effects.js"
27
+ import { ACTION_FX } from "./action-fx.js"
28
+ import { LOOP_SUBJECTS } from "./loop-subject.js"
29
+ import { TRANSITIONS } from "./transitions.js"
30
+ import { CHARACTER_FX } from "./character-fx.js"
31
+ import { POSES } from "./pose.js"
32
+ import { MATERIALS } from "./materials.js"
33
+ import { HELD_PROPS } from "./held-prop.js"
34
+ import { CAMERA_MOTIONS } from "./camera-motions.js"
35
+ import { LIGHTINGS, LIGHTING_CATEGORY_ORDER, LIGHTING_CATEGORY_LABELS, LIGHTING_FIELD_BY_CATEGORY } from "./lighting.js"
36
+ import { TEMPORALS } from "./temporal.js"
37
+ import { EXPOSURE_SETTINGS } from "./exposure-settings.js"
38
+ import { MUSIC_GENRES, MUSIC_ERAS } from "./music-genre.js"
39
+ import { MUSIC_ENERGIES, MUSIC_EMOTIONS, MUSIC_VIBES } from "./music-mood.js"
40
+ import { INSTRUMENTS, PRODUCTION_STYLES, VOCAL_PRESENCE, SINGING_STYLES } from "./instrumentation.js"
41
+ import { VOICE_AGES, VOICE_GENDERS, VOICE_LANGUAGES, VOICE_ACCENTS, VOICE_TIMBRES } from "./voice-character.js"
42
+ import { VOICE_PACES, VOICE_EMOTIONS, VOICE_ARCHETYPES } from "./voice-delivery.js"
13
43
 
14
44
  // ─── Descriptor model ────────────────────────────────────────────────────────
15
45
 
@@ -57,6 +87,16 @@ export type PickerAnalyzerDescriptor =
57
87
 
58
88
  const PERSON_EXCLUDED = new Set<string>(["age-custom"])
59
89
 
90
+ /** Tag catalog entries with an explicit dimension — for discriminated pickers
91
+ * whose fields live in SEPARATE catalogs (music/voice) rather than one
92
+ * discriminated catalog. */
93
+ function tagDim<T extends { id: string; label: string; description: string }>(
94
+ arr: ReadonlyArray<T>,
95
+ dimension: string,
96
+ ): ReadonlyArray<AnalyzerEntry> {
97
+ return arr.map((e) => ({ id: e.id, label: e.label, description: e.description, dimension }))
98
+ }
99
+
60
100
  const personCleanup: ApplyCleanup = (patch, mode) => {
61
101
  if (mode === "override") {
62
102
  patch.customAge = undefined
@@ -113,10 +153,160 @@ export const PICKER_ANALYZER_REGISTRY = {
113
153
  label: "Camera / Film Stock",
114
154
  entries: CAMERA_FORMATS as ReadonlyArray<AnalyzerEntry>,
115
155
  },
156
+
157
+ // ─── Text-to-picker expansion (Cine AI Fill): every remaining catalog ─────
158
+ // Flat single-value pickers — field names match the picker wiring's
159
+ // valueField (and the node-data shape the published-app input card writes).
160
+ setting: { kind: "flat", toolName: "emit_setting", field: "setting", label: "Setting", entries: SETTINGS as ReadonlyArray<AnalyzerEntry> },
161
+ atmosphere: { kind: "flat", toolName: "emit_atmosphere", field: "atmosphere", label: "Atmosphere", entries: ATMOSPHERES as ReadonlyArray<AnalyzerEntry> },
162
+ style: { kind: "flat", toolName: "emit_style", field: "style", label: "Style", entries: STYLES as ReadonlyArray<AnalyzerEntry> },
163
+ mood: { kind: "flat", toolName: "emit_mood", field: "mood", label: "Mood", entries: MOODS as ReadonlyArray<AnalyzerEntry> },
164
+ "color-look": { kind: "flat", toolName: "emit_color_look", field: "colorLook", label: "Color / Look", entries: COLOR_LOOKS as ReadonlyArray<AnalyzerEntry> },
165
+ photographer: { kind: "flat", toolName: "emit_photographer", field: "photographer", label: "Photographer / Artist", entries: PHOTOGRAPHERS as ReadonlyArray<AnalyzerEntry> },
166
+ aesthetic: { kind: "flat", toolName: "emit_aesthetic", field: "aesthetic", label: "Aesthetic / Microtrend", entries: AESTHETICS as ReadonlyArray<AnalyzerEntry> },
167
+ era: { kind: "flat", toolName: "emit_era", field: "era", label: "Era / Period", entries: ERAS as ReadonlyArray<AnalyzerEntry> },
168
+ "photo-genre": { kind: "flat", toolName: "emit_photo_genre", field: "photoGenre", label: "Photo Genre", entries: PHOTO_GENRES as ReadonlyArray<AnalyzerEntry> },
169
+ backdrop: { kind: "flat", toolName: "emit_backdrop", field: "backdrop", label: "Backdrop", entries: BACKDROPS as ReadonlyArray<AnalyzerEntry> },
170
+ "render-quality": { kind: "flat", toolName: "emit_render_quality", field: "renderQuality", label: "Render Quality", entries: RENDER_QUALITIES as ReadonlyArray<AnalyzerEntry> },
171
+ "composition-effects": { kind: "flat", toolName: "emit_composition_effects", field: "compositionEffect", label: "Composition Effect", entries: COMPOSITION_EFFECTS as ReadonlyArray<AnalyzerEntry> },
172
+ "post-process-effects": { kind: "flat", toolName: "emit_post_process_effects", field: "postProcess", label: "Post-Process Effect", entries: POST_PROCESS_EFFECTS as ReadonlyArray<AnalyzerEntry> },
173
+ "action-fx": { kind: "flat", toolName: "emit_action_fx", field: "actionFx", label: "Action FX", entries: ACTION_FX as ReadonlyArray<AnalyzerEntry> },
174
+ "loop-subject": { kind: "flat", toolName: "emit_loop_subject", field: "loopSubject", label: "Loop Subject", entries: LOOP_SUBJECTS as ReadonlyArray<AnalyzerEntry> },
175
+ transition: { kind: "flat", toolName: "emit_transition", field: "transition", label: "Transition", entries: TRANSITIONS as ReadonlyArray<AnalyzerEntry> },
176
+ "character-fx": { kind: "flat", toolName: "emit_character_fx", field: "characterFx", label: "Character FX", entries: CHARACTER_FX as ReadonlyArray<AnalyzerEntry> },
177
+ pose: { kind: "flat", toolName: "emit_pose", field: "pose", label: "Pose", entries: POSES as ReadonlyArray<AnalyzerEntry> },
178
+ material: { kind: "flat", toolName: "emit_material", field: "material", label: "Material", entries: MATERIALS as ReadonlyArray<AnalyzerEntry> },
179
+ "held-prop": { kind: "flat", toolName: "emit_held_prop", field: "heldProp", label: "Held Prop", entries: HELD_PROPS as ReadonlyArray<AnalyzerEntry> },
180
+ "camera-motion": { kind: "flat", toolName: "emit_camera_motion", field: "cameraMotion", label: "Camera Motion", entries: CAMERA_MOTIONS as ReadonlyArray<AnalyzerEntry> },
181
+ animal: { kind: "flat", toolName: "emit_animal", field: "animal", label: "Animal", entries: ANIMALS as ReadonlyArray<AnalyzerEntry> },
182
+ vehicle: { kind: "flat", toolName: "emit_vehicle", field: "vehicle", label: "Vehicle", entries: VEHICLES as ReadonlyArray<AnalyzerEntry> },
183
+ weapon: { kind: "flat", toolName: "emit_weapon", field: "weapon", label: "Weapon", entries: WEAPONS as ReadonlyArray<AnalyzerEntry> },
184
+ furniture: { kind: "flat", toolName: "emit_furniture", field: "furniture", label: "Furniture", entries: FURNITURE as ReadonlyArray<AnalyzerEntry> },
185
+
186
+ // Discriminated multi-dim pickers. lighting/temporal/exposure discriminate
187
+ // on the single catalog's `category`; the sound/voice pickers span several
188
+ // per-field catalogs, so their entries are synthesized with an explicit
189
+ // `dimension` tag (tagDim below) — same wire shape either way.
190
+ lighting: {
191
+ kind: "discriminated",
192
+ toolName: "emit_lighting",
193
+ discriminator: "category",
194
+ order: LIGHTING_CATEGORY_ORDER as ReadonlyArray<string>,
195
+ fieldByKey: LIGHTING_FIELD_BY_CATEGORY as Readonly<Record<string, string>>,
196
+ labels: LIGHTING_CATEGORY_LABELS as Readonly<Record<string, string>>,
197
+ entries: LIGHTINGS as ReadonlyArray<AnalyzerEntry>,
198
+ limitFn: () => 1,
199
+ },
200
+ temporal: {
201
+ kind: "discriminated",
202
+ toolName: "emit_temporal",
203
+ discriminator: "category",
204
+ order: ["speed", "freeze", "direction", "shutter"],
205
+ fieldByKey: { speed: "temporalSpeed", freeze: "temporalFreeze", direction: "temporalDirection", shutter: "temporalShutter" },
206
+ labels: { speed: "Playback Speed", freeze: "Freeze", direction: "Direction", shutter: "Shutter" },
207
+ entries: TEMPORALS as ReadonlyArray<AnalyzerEntry>,
208
+ limitFn: () => 1,
209
+ },
210
+ "exposure-settings": {
211
+ kind: "discriminated",
212
+ toolName: "emit_exposure_settings",
213
+ discriminator: "category",
214
+ order: ["aperture", "shutter-speed", "iso"],
215
+ fieldByKey: { aperture: "aperture", "shutter-speed": "shutterSpeed", iso: "isoValue" },
216
+ labels: { aperture: "Aperture", "shutter-speed": "Shutter Speed", iso: "ISO" },
217
+ entries: EXPOSURE_SETTINGS as ReadonlyArray<AnalyzerEntry>,
218
+ limitFn: () => 1,
219
+ },
220
+ "music-genre": {
221
+ kind: "discriminated",
222
+ toolName: "emit_music_genre",
223
+ discriminator: "dimension",
224
+ order: ["genre", "subgenre", "era"],
225
+ fieldByKey: { genre: "genre", subgenre: "subgenre", era: "era" },
226
+ labels: { genre: "Genre", subgenre: "Subgenre", era: "Era" },
227
+ entries: [
228
+ ...tagDim(MUSIC_GENRES, "genre"),
229
+ // Subgenres carry promptHint but no description — the hint doubles as
230
+ // the legend text (it describes the sound well enough for matching).
231
+ ...MUSIC_GENRES.flatMap((g) =>
232
+ g.subgenres.map((s) => ({ id: s.id, label: s.label, description: s.promptHint, dimension: "subgenre" })),
233
+ ),
234
+ ...tagDim(MUSIC_ERAS, "era"),
235
+ ],
236
+ limitFn: (k) => (k === "genre" ? 2 : 1),
237
+ },
238
+ "music-mood": {
239
+ kind: "discriminated",
240
+ toolName: "emit_music_mood",
241
+ discriminator: "dimension",
242
+ order: ["energy", "emotion", "vibe"],
243
+ fieldByKey: { energy: "energy", emotion: "emotion", vibe: "vibe" },
244
+ labels: { energy: "Energy", emotion: "Emotion", vibe: "Vibe" },
245
+ entries: [...tagDim(MUSIC_ENERGIES, "energy"), ...tagDim(MUSIC_EMOTIONS, "emotion"), ...tagDim(MUSIC_VIBES, "vibe")],
246
+ limitFn: (k) => (k === "energy" ? 1 : 2),
247
+ },
248
+ instrumentation: {
249
+ kind: "discriminated",
250
+ toolName: "emit_instrumentation",
251
+ discriminator: "dimension",
252
+ order: ["instruments", "production", "vocalPresence", "singingStyle"],
253
+ fieldByKey: { instruments: "instruments", production: "production", vocalPresence: "vocalPresence", singingStyle: "singingStyle" },
254
+ labels: { instruments: "Instruments", production: "Production Style", vocalPresence: "Vocal Presence", singingStyle: "Singing Style" },
255
+ entries: [
256
+ ...tagDim(INSTRUMENTS, "instruments"),
257
+ ...tagDim(PRODUCTION_STYLES, "production"),
258
+ ...tagDim(VOCAL_PRESENCE, "vocalPresence"),
259
+ ...tagDim(SINGING_STYLES, "singingStyle"),
260
+ ],
261
+ limitFn: (k) => (k === "instruments" ? 3 : k === "production" ? 1 : 2),
262
+ },
263
+ "voice-character": {
264
+ kind: "discriminated",
265
+ toolName: "emit_voice_character",
266
+ discriminator: "dimension",
267
+ order: ["age", "gender", "language", "accent", "timbre"],
268
+ fieldByKey: { age: "age", gender: "gender", language: "language", accent: "accent", timbre: "timbre" },
269
+ labels: { age: "Age", gender: "Gender", language: "Language", accent: "Accent", timbre: "Timbre" },
270
+ entries: [
271
+ ...tagDim(VOICE_AGES, "age"),
272
+ ...tagDim(VOICE_GENDERS, "gender"),
273
+ ...tagDim(VOICE_LANGUAGES, "language"),
274
+ ...tagDim(VOICE_ACCENTS, "accent"),
275
+ ...tagDim(VOICE_TIMBRES, "timbre"),
276
+ ],
277
+ limitFn: (k) => (k === "language" ? 2 : 1),
278
+ },
279
+ "voice-delivery": {
280
+ kind: "discriminated",
281
+ toolName: "emit_voice_delivery",
282
+ discriminator: "dimension",
283
+ order: ["pace", "emotion", "archetype"],
284
+ fieldByKey: { pace: "pace", emotion: "emotion", archetype: "archetype" },
285
+ labels: { pace: "Pace", emotion: "Emotion", archetype: "Archetype" },
286
+ entries: [...tagDim(VOICE_PACES, "pace"), ...tagDim(VOICE_EMOTIONS, "emotion"), ...tagDim(VOICE_ARCHETYPES, "archetype")],
287
+ limitFn: () => 1,
288
+ },
116
289
  } satisfies Record<string, PickerAnalyzerDescriptor>
117
290
 
118
291
  export type PickerType = keyof typeof PICKER_ANALYZER_REGISTRY
119
292
  export const PICKER_TYPES = Object.keys(PICKER_ANALYZER_REGISTRY) as PickerType[]
293
+
294
+ /**
295
+ * Family grouping for BATCHED analysis. A single call across all 38 catalogs
296
+ * carries a ~211k-char legend (~53k tokens — measured 2026-08-09, the
297
+ * measure-first probe from the text-to-picker spec), which is slow, costly,
298
+ * and dilutes per-section accuracy. The text-to-picker route fans out one
299
+ * structured call per family (6-15k tokens each) and merges. Mirrors the
300
+ * build-brief's §5 UI grouping so Cine can reuse the same partition.
301
+ */
302
+ export const PICKER_ANALYZER_FAMILIES: Readonly<Record<string, ReadonlyArray<PickerType>>> = {
303
+ scene: ["setting", "atmosphere", "backdrop", "era", "temporal"],
304
+ look: ["style", "color-look", "mood", "aesthetic", "photographer", "photo-genre", "render-quality", "composition-effects", "post-process-effects"],
305
+ camera: ["framing", "camera-motion", "lens", "camera-format", "lighting", "exposure-settings"],
306
+ character: ["person", "styling", "pose", "character-fx"],
307
+ elements: ["animal", "vehicle", "weapon", "furniture", "held-prop", "material", "action-fx", "loop-subject", "transition"],
308
+ audio: ["music-genre", "music-mood", "instrumentation", "voice-character", "voice-delivery"],
309
+ }
120
310
  export const ANALYZABLE_PICKER_TYPES: ReadonlySet<string> = new Set(PICKER_TYPES)
121
311
  export function isAnalyzablePicker(t: string): t is PickerType {
122
312
  return ANALYZABLE_PICKER_TYPES.has(t)