@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/dist/index.cjs +628 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +499 -2
- package/dist/index.d.ts +499 -2
- package/dist/index.js +622 -17
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/doctrine-roster-completeness.test.ts +59 -0
- package/src/__tests__/gemini-omni-inputs.test.ts +68 -0
- package/src/__tests__/picker-analyzer-registry.test.ts +11 -1
- package/src/__tests__/provider-prompt-doctrine.test.ts +5 -3
- package/src/__tests__/seedance-2-inputs.test.ts +48 -0
- package/src/__tests__/veo-i2v-inputs.test.ts +58 -0
- package/src/gemini-omni-inputs.ts +73 -0
- package/src/index.ts +3 -0
- package/src/picker-analyzer-registry.ts +190 -0
- package/src/picker-wiring.ts +336 -0
- package/src/prompt-wizard-categories.ts +3 -2
- package/src/provider-prompt-doctrine.ts +228 -5
- package/src/resolve-prompt.ts +12 -1
- package/src/seedance-2-inputs.ts +10 -3
- package/src/veo-i2v-inputs.ts +68 -0
- package/src/video-reference-resolver.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodaro/prompts",
|
|
3
|
-
"version": "1.
|
|
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([
|
|
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(
|
|
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
|
-
|
|
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("
|
|
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)
|