@nodaro/shared 1.21.0 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +240 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +343 -7
- package/dist/index.d.ts +343 -7
- package/dist/index.js +229 -50
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/llm-models.test.ts +157 -0
- package/src/__tests__/video-analysis-catalog-sync.test.ts +102 -0
- package/src/__tests__/video-analysis-pricing.test.ts +5 -4
- package/src/__tests__/video-analysis.test.ts +133 -1
- package/src/index.ts +6 -0
- package/src/llm-models.ts +184 -11
- package/src/model-catalog.ts +20 -20
- package/src/video-analysis-pricing.ts +21 -18
- package/src/video-analysis.ts +191 -1
package/package.json
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
resolveLlmCreditId,
|
|
11
11
|
motionGraphicsFeature,
|
|
12
12
|
effectiveReasoningEffort,
|
|
13
|
+
supportsAdvancedMode,
|
|
14
|
+
availableReasoningEfforts,
|
|
13
15
|
} from "../llm-models.js"
|
|
14
16
|
import type { LlmModelDef, LlmTier, LlmFeature } from "../llm-models.js"
|
|
15
17
|
import { PIPELINE_PINNABLE_SCRIPT_LLMS } from "../pipeline-types.js"
|
|
@@ -609,3 +611,158 @@ describe("buildLlmCreditIdentifier effort bump (xhigh/max only)", () => {
|
|
|
609
611
|
expect(resolveLlmCreditId("llm-chat", { llmModel: "gpt-5.6-terra", reasoningEffort: "max" })).toBe("llm-chat:premium")
|
|
610
612
|
})
|
|
611
613
|
})
|
|
614
|
+
|
|
615
|
+
describe("direct-vendor lane declarations", () => {
|
|
616
|
+
it("no model declares BOTH preferKie and preferDirect", () => {
|
|
617
|
+
// They are the two halves of one idea (which lane goes first). Declaring
|
|
618
|
+
// both is meaningless and would make routing depend on branch order in
|
|
619
|
+
// llm-client rather than on the registry.
|
|
620
|
+
const conflicted = LLM_MODELS.filter((m) => m.preferKie && m.preferDirect).map((m) => m.id)
|
|
621
|
+
expect(conflicted).toEqual([])
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
it("preferDirect is only meaningful alongside a directGeminiModel", () => {
|
|
625
|
+
const orphaned = LLM_MODELS.filter((m) => m.preferDirect && !m.directGeminiModel).map((m) => m.id)
|
|
626
|
+
expect(orphaned).toEqual([])
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it("every direct-lane model id is a non-empty, non-placeholder string", () => {
|
|
630
|
+
for (const m of LLM_MODELS.filter((x) => x.directGeminiModel)) {
|
|
631
|
+
expect(m.directGeminiModel!.length, m.id).toBeGreaterThan(0)
|
|
632
|
+
expect(m.directGeminiModel, m.id).not.toContain(" ")
|
|
633
|
+
}
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it("only google-vendor models carry a Gemini direct lane", () => {
|
|
637
|
+
const misvendored = LLM_MODELS.filter((m) => m.directGeminiModel && m.vendor !== "google").map((m) => m.id)
|
|
638
|
+
expect(misvendored).toEqual([])
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it("getLlmModel resolves a model by its direct Google id", () => {
|
|
642
|
+
// The `-preview`-suffixed Google ids differ from our canonical ids, and
|
|
643
|
+
// cost/usage reconciliation looks models up by whatever id the wire used.
|
|
644
|
+
expect(getLlmModel("gemini-3.1-pro-preview")?.id).toBe("gemini-3.1-pro")
|
|
645
|
+
expect(getLlmModel("gemini-3-flash-preview")?.id).toBe("gemini-3-flash")
|
|
646
|
+
})
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
describe("advanced mode", () => {
|
|
650
|
+
it("is available exactly on models with a direct Gemini lane", () => {
|
|
651
|
+
for (const m of LLM_MODELS) {
|
|
652
|
+
expect(supportsAdvancedMode(m.id), m.id).toBe(Boolean(m.directGeminiModel))
|
|
653
|
+
}
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it("is unavailable for an unknown or missing model id", () => {
|
|
657
|
+
expect(supportsAdvancedMode(undefined)).toBe(false)
|
|
658
|
+
expect(supportsAdvancedMode("not-a-real-model")).toBe(false)
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
it("bumps the credit tier one step", () => {
|
|
662
|
+
// gemini-3-flash is economy → standard (standard renders as the bare feature)
|
|
663
|
+
expect(buildLlmCreditIdentifier("llm-chat", "gemini-3-flash")).toBe("llm-chat:economy")
|
|
664
|
+
expect(buildLlmCreditIdentifier("llm-chat", "gemini-3-flash", undefined, true)).toBe("llm-chat")
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it("is independent of the effort bump — advanced adds exactly one step", () => {
|
|
668
|
+
// No Gemini model declares xhigh/max today (their KIE-safe ceiling is
|
|
669
|
+
// `high`, which never bumps), so the two bumps cannot currently stack in
|
|
670
|
+
// practice — `max` clamps to `high` first. What must hold regardless is
|
|
671
|
+
// that advanced adds exactly one step on top of whatever the
|
|
672
|
+
// effort-clamped tier already is.
|
|
673
|
+
for (const effort of [undefined, "low", "high", "max"]) {
|
|
674
|
+
const plain = buildLlmCreditIdentifier("llm-chat", "gemini-3.6-flash", effort)
|
|
675
|
+
const advanced = buildLlmCreditIdentifier("llm-chat", "gemini-3.6-flash", effort, true)
|
|
676
|
+
expect(plain, `effort=${effort}`).toBe("llm-chat:economy")
|
|
677
|
+
expect(advanced, `effort=${effort}`).toBe("llm-chat")
|
|
678
|
+
}
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
it("no advanced-capable model declares xhigh/max — the UI copy depends on it", () => {
|
|
682
|
+
// `EFFORT_LABELS` in reasoning-effort-select.tsx labels xhigh/max "may bill
|
|
683
|
+
// one tier up" and its comment explains that the effort bump and the
|
|
684
|
+
// advanced bump can never both apply, because Advanced is Gemini-only and
|
|
685
|
+
// no Gemini model reaches xhigh/max. That was a note asking a future reader
|
|
686
|
+
// to remember; this is the check that fails instead.
|
|
687
|
+
//
|
|
688
|
+
// If this ever goes red, the two bumps CAN stack: reword the labels to say
|
|
689
|
+
// so and revisit whether a double bump is the price we want.
|
|
690
|
+
for (const m of LLM_MODELS) {
|
|
691
|
+
if (!supportsAdvancedMode(m.id)) continue
|
|
692
|
+
const levels = [...(m.reasoningEfforts ?? []), ...(m.directReasoningEfforts ?? [])]
|
|
693
|
+
expect(levels, `${m.id} can run advanced AND declares a tier-bumping effort`)
|
|
694
|
+
.not.toEqual(expect.arrayContaining(["xhigh"]))
|
|
695
|
+
expect(levels, `${m.id} can run advanced AND declares a tier-bumping effort`)
|
|
696
|
+
.not.toEqual(expect.arrayContaining(["max"]))
|
|
697
|
+
}
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it("never bumps past premium", () => {
|
|
701
|
+
expect(buildLlmCreditIdentifier("llm-chat", "gemini-3.1-pro", "max", true)).toBe("llm-chat:premium")
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it("ignores the flag on a model that cannot run advanced (no silent overcharge)", () => {
|
|
705
|
+
// gpt-5.2 has no direct lane — a stale advancedMode flag must not inflate it.
|
|
706
|
+
expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.2", undefined, true))
|
|
707
|
+
.toBe(buildLlmCreditIdentifier("llm-chat", "gpt-5.2"))
|
|
708
|
+
expect(buildLlmCreditIdentifier("llm-chat", "claude-opus-4.7", undefined, true))
|
|
709
|
+
.toBe(buildLlmCreditIdentifier("llm-chat", "claude-opus-4.7"))
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
it("back-compat: omitting the 4th arg is identical to before for every model", () => {
|
|
713
|
+
for (const m of LLM_MODELS) {
|
|
714
|
+
expect(buildLlmCreditIdentifier("x", m.id, "high", false)).toBe(buildLlmCreditIdentifier("x", m.id, "high"))
|
|
715
|
+
}
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it("resolveLlmCreditId reads advancedMode from the raw body", () => {
|
|
719
|
+
expect(resolveLlmCreditId("llm-chat", { llmModel: "gemini-3-flash", advancedMode: true })).toBe("llm-chat")
|
|
720
|
+
expect(resolveLlmCreditId("llm-chat", { llmModel: "gemini-3-flash" })).toBe("llm-chat:economy")
|
|
721
|
+
// Only a real boolean true counts — a truthy string must not bump.
|
|
722
|
+
expect(resolveLlmCreditId("llm-chat", { llmModel: "gemini-3-flash", advancedMode: "yes" })).toBe("llm-chat:economy")
|
|
723
|
+
})
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
describe("lane-aware reasoning efforts", () => {
|
|
727
|
+
it("widens the ladder on the direct lane where the vendor accepts more", () => {
|
|
728
|
+
expect(availableReasoningEfforts("gemini-3.6-flash")).toEqual(["low", "high"])
|
|
729
|
+
expect(availableReasoningEfforts("gemini-3.6-flash", true)).toEqual(["none", "low", "medium", "high"])
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it("exposes an effort lever on models that have none via KIE", () => {
|
|
733
|
+
// gemini-3-flash declares no reasoningEfforts at all — Advanced is the
|
|
734
|
+
// only way its effort picker appears.
|
|
735
|
+
expect(availableReasoningEfforts("gemini-3-flash")).toEqual([])
|
|
736
|
+
expect(availableReasoningEfforts("gemini-3-flash", true)).toEqual(["none", "low", "medium", "high"])
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
it("respects a shorter direct ladder (3.1 Pro has no minimal tier)", () => {
|
|
740
|
+
expect(availableReasoningEfforts("gemini-3.1-pro", true)).toEqual(["low", "medium", "high"])
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
it("ignores the advanced flag on a model with no direct lane", () => {
|
|
744
|
+
const claude = availableReasoningEfforts("claude-sonnet-4.6")
|
|
745
|
+
expect(availableReasoningEfforts("claude-sonnet-4.6", true)).toEqual(claude)
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
it("clamps against the lane's ladder, not the other lane's", () => {
|
|
749
|
+
// `medium` is not on 3.6-flash's KIE ladder → clamps down to `low`.
|
|
750
|
+
expect(effectiveReasoningEffort("gemini-3.6-flash", "medium")).toBe("low")
|
|
751
|
+
// On the direct lane `medium` is a real level → survives.
|
|
752
|
+
expect(effectiveReasoningEffort("gemini-3.6-flash", "medium", true)).toBe("medium")
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
it("every directReasoningEfforts entry is a valid effort, ascending", () => {
|
|
756
|
+
for (const m of LLM_MODELS.filter((x) => x.directReasoningEfforts)) {
|
|
757
|
+
const levels = m.directReasoningEfforts!
|
|
758
|
+
for (const l of levels) expect(LLM_REASONING_EFFORTS, m.id).toContain(l)
|
|
759
|
+
const ranks = levels.map((l) => LLM_REASONING_EFFORTS.indexOf(l))
|
|
760
|
+
expect(ranks, `${m.id} must be ascending`).toEqual([...ranks].sort((a, b) => a - b))
|
|
761
|
+
}
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
it("only advanced-capable models declare a direct ladder", () => {
|
|
765
|
+
const bad = LLM_MODELS.filter((m) => m.directReasoningEfforts && !supportsAdvancedMode(m.id)).map((m) => m.id)
|
|
766
|
+
expect(bad).toEqual([])
|
|
767
|
+
})
|
|
768
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model catalog hand-copies video-analysis credit values that already live
|
|
3
|
+
* in `VIDEO_ANALYSIS_BUCKET_CREDITS`. Nothing cross-checked the two, so a
|
|
4
|
+
* reprice could update the table (and the DB migration) while the catalog kept
|
|
5
|
+
* quoting the old numbers — and the catalog is what the model browser shows a
|
|
6
|
+
* user BEFORE they run anything.
|
|
7
|
+
*
|
|
8
|
+
* This is the guard that was missing.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from "vitest"
|
|
11
|
+
import { MODEL_CATALOG } from "../model-catalog.js"
|
|
12
|
+
import {
|
|
13
|
+
VIDEO_ANALYSIS_BUCKET_CREDITS,
|
|
14
|
+
VIDEO_ANALYSIS_DURATION_BUCKETS,
|
|
15
|
+
VIDEO_ANALYSIS_MAX_DURATION_SEC,
|
|
16
|
+
buildVideoAnalysisCreditId,
|
|
17
|
+
} from "../video-analysis-pricing.js"
|
|
18
|
+
import { DEFAULT_VIDEO_ANALYSIS_MODEL } from "../llm-models.js"
|
|
19
|
+
|
|
20
|
+
/** Every `video-analysis:*` pricing row declared anywhere in the catalog. */
|
|
21
|
+
const catalogRows = Object.values(MODEL_CATALOG)
|
|
22
|
+
.flatMap((m) => (m.pricing ?? []) as ReadonlyArray<{ identifier: string; credits: number }>)
|
|
23
|
+
.filter((r) => r.identifier.startsWith("video-analysis:"))
|
|
24
|
+
|
|
25
|
+
describe("model catalog video-analysis pricing", () => {
|
|
26
|
+
it("declares at least one row (guard is actually wired to something)", () => {
|
|
27
|
+
expect(catalogRows.length).toBeGreaterThan(0)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("every bucketed catalog row matches VIDEO_ANALYSIS_BUCKET_CREDITS exactly", () => {
|
|
31
|
+
const bucketed = catalogRows.filter((r) => /:\d+s$/.test(r.identifier))
|
|
32
|
+
expect(bucketed.length).toBeGreaterThan(0)
|
|
33
|
+
for (const row of bucketed) {
|
|
34
|
+
expect(
|
|
35
|
+
row.credits,
|
|
36
|
+
`catalog "${row.identifier}" = ${row.credits} but the table says ${VIDEO_ANALYSIS_BUCKET_CREDITS[row.identifier]}`,
|
|
37
|
+
).toBe(VIDEO_ANALYSIS_BUCKET_CREDITS[row.identifier])
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("every bare (no-duration) catalog row equals its max-duration ceiling", () => {
|
|
42
|
+
// A bare id means "duration unknown", which the route prices at the ceiling.
|
|
43
|
+
const bare = catalogRows.filter((r) => !/:\d+s$/.test(r.identifier))
|
|
44
|
+
expect(bare.length).toBeGreaterThan(0)
|
|
45
|
+
for (const row of bare) {
|
|
46
|
+
const ceiling = VIDEO_ANALYSIS_BUCKET_CREDITS[`${row.identifier}:${VIDEO_ANALYSIS_MAX_DURATION_SEC}s`]
|
|
47
|
+
expect(ceiling, `no ceiling row for ${row.identifier}`).toBeDefined()
|
|
48
|
+
expect(row.credits, `catalog "${row.identifier}" must equal its ${VIDEO_ANALYSIS_MAX_DURATION_SEC}s ceiling`).toBe(ceiling)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("catalog covers every bucket it claims a model supports", () => {
|
|
53
|
+
const byModel = new Map<string, Set<number>>()
|
|
54
|
+
for (const r of catalogRows) {
|
|
55
|
+
const m = /^video-analysis:(.+):(\d+)s$/.exec(r.identifier)
|
|
56
|
+
if (!m) continue
|
|
57
|
+
if (!byModel.has(m[1]!)) byModel.set(m[1]!, new Set())
|
|
58
|
+
byModel.get(m[1]!)!.add(Number(m[2]))
|
|
59
|
+
}
|
|
60
|
+
for (const [model, buckets] of byModel) {
|
|
61
|
+
expect([...buckets].sort((a, b) => a - b), `${model} bucket coverage`)
|
|
62
|
+
.toEqual([...VIDEO_ANALYSIS_DURATION_BUCKETS])
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The BARE `video-analysis` node-type id — the one every colon-scoped guard misses.
|
|
69
|
+
*
|
|
70
|
+
* `getModelIdentifier` (frontend) has no video-analysis branch, so it falls through
|
|
71
|
+
* to `return nodeType` and the run-credits estimate resolves this exact id. It was
|
|
72
|
+
* seeded at 3 credits in migration 247 with the comment "= flash 600s" and then
|
|
73
|
+
* skipped by FIVE consecutive repricings (248, 259, 273, 275, 276), every one of
|
|
74
|
+
* which matched only `video-analysis:` prefixes — including this test file's own
|
|
75
|
+
* `catalogRows` filter above. A real run reserves 21-200, so the pre-run balance
|
|
76
|
+
* check was quoting 3 for a run it would then refuse.
|
|
77
|
+
*
|
|
78
|
+
* The value is the MAXIMUM of the table, not the default tier at the ceiling
|
|
79
|
+
* bucket: this test's first version pinned the latter and failed immediately,
|
|
80
|
+
* because the default (pro) tops out at 120 while `mixed` reaches 200. Reached only
|
|
81
|
+
* when BOTH model and duration are unknown, so it has to bound every row.
|
|
82
|
+
*/
|
|
83
|
+
describe("bare video-analysis node-type credit id", () => {
|
|
84
|
+
it("bounds EVERY bucketed row — an unknown-duration estimate must never UNDER-quote", () => {
|
|
85
|
+
const ceiling = Math.max(...Object.values(VIDEO_ANALYSIS_BUCKET_CREDITS))
|
|
86
|
+
for (const [id, credits] of Object.entries(VIDEO_ANALYSIS_BUCKET_CREDITS)) {
|
|
87
|
+
expect(credits, `${id} exceeds the bare-id ceiling ${ceiling}`).toBeLessThanOrEqual(ceiling)
|
|
88
|
+
}
|
|
89
|
+
// The migration writes this number; keep them in lockstep (277 wrote 200, 278
|
|
90
|
+
// writes 739 after the measured reprice).
|
|
91
|
+
expect(ceiling).toBe(739)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("the default tier at the ceiling bucket is NOT a safe fallback — mixed costs more", () => {
|
|
95
|
+
// Pins the reason the bare id is the table max rather than the intuitive
|
|
96
|
+
// "default model, longest video" value, so nobody re-derives it wrongly.
|
|
97
|
+
const defaultAtCeiling = VIDEO_ANALYSIS_BUCKET_CREDITS[
|
|
98
|
+
buildVideoAnalysisCreditId(DEFAULT_VIDEO_ANALYSIS_MODEL, VIDEO_ANALYSIS_MAX_DURATION_SEC)
|
|
99
|
+
]!
|
|
100
|
+
expect(defaultAtCeiling).toBeLessThan(Math.max(...Object.values(VIDEO_ANALYSIS_BUCKET_CREDITS)))
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -113,10 +113,11 @@ describe("video-analysis-pricing", () => {
|
|
|
113
113
|
}
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
-
// Full drift-detection against the live $-formula lives in
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
116
|
+
// Full drift-detection against the live $-formula lives in the PRIVATE
|
|
117
|
+
// plugin repo (src/plugins/video-analysis/__tests__/cost.test.ts) — the
|
|
118
|
+
// formula moved there in 2026-07 and the app-side test was deleted with it,
|
|
119
|
+
// so nothing in THIS repo can recompute these numbers. This is a lightweight
|
|
120
|
+
// shape check that the precomputed table covers every legal id.
|
|
120
121
|
it("VIDEO_ANALYSIS_BUCKET_CREDITS has a positive-integer entry for every model × bucket id", () => {
|
|
121
122
|
for (const model of VIDEO_ANALYSIS_LLM_MODELS) {
|
|
122
123
|
for (const bucketSec of VIDEO_ANALYSIS_DURATION_BUCKETS) {
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
renderAnalyzedScene, isOversizedScene, aspectRatioFromDims,
|
|
6
6
|
entitySlotSchema, analyzedSceneSchema,
|
|
7
7
|
rewriteSceneBindings, dropUnknownBindings,
|
|
8
|
-
rewriteSpeakerSlots, dropUnknownSpeakers,
|
|
8
|
+
rewriteSpeakerSlots, dropUnknownSpeakers, mergeClipLook,
|
|
9
|
+
VIDEO_ANALYSIS_SPEED_EFFECTS, VIDEO_ANALYSIS_SHOT_ANGLES, VIDEO_ANALYSIS_FACELESS_ANGLES,
|
|
10
|
+
VIDEO_ANALYSIS_VISUAL_EFFECTS, VIDEO_ANALYSIS_TRANSITIONS,
|
|
9
11
|
VIDEO_ANALYSIS_MAX_VARIATIONS, VIDEO_ANALYSIS_VARIATION_SLUGS, VIDEO_ANALYSIS_DEFAULT_VARIATION,
|
|
10
12
|
type EntitySlot, type AudioLayer,
|
|
11
13
|
} from "../video-analysis.js"
|
|
@@ -216,6 +218,136 @@ describe("speech attribution (speakerSlot)", () => {
|
|
|
216
218
|
})
|
|
217
219
|
})
|
|
218
220
|
|
|
221
|
+
describe("cinematography fields (angle / speed / onScreenText / look)", () => {
|
|
222
|
+
it("carries angle and speed as closed enums through a window round-trip", () => {
|
|
223
|
+
const parsed = windowAnalysisSchema.parse({
|
|
224
|
+
slots: [slot],
|
|
225
|
+
scenes: [{ ...baseScene, angle: "low", speed: "slow-motion", onScreenText: "ACT I" }],
|
|
226
|
+
})
|
|
227
|
+
expect(parsed.scenes[0]).toMatchObject({ angle: "low", speed: "slow-motion", onScreenText: "ACT I" })
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("supports the RELATIONAL viewpoints, so shotType keeps the size", () => {
|
|
231
|
+
// These were conventions inside the `shotType` list, competing with the
|
|
232
|
+
// sizes for one slot — so an over-the-shoulder MEDIUM had to throw one away.
|
|
233
|
+
const parsed = windowAnalysisSchema.parse({
|
|
234
|
+
slots: [slot],
|
|
235
|
+
scenes: [{ ...baseScene, shotType: "Medium", angle: "over-the-shoulder" }],
|
|
236
|
+
})
|
|
237
|
+
expect(parsed.scenes[0]).toMatchObject({ shotType: "Medium", angle: "over-the-shoulder" })
|
|
238
|
+
for (const a of ["pov", "profile", "from-behind"]) {
|
|
239
|
+
expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene, angle: a }] }).success).toBe(true)
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("carries picture EFFECTS as an array — a shot can be grainy and vignetted", () => {
|
|
244
|
+
const parsed = windowAnalysisSchema.parse({
|
|
245
|
+
slots: [slot], scenes: [{ ...baseScene, effects: ["grain", "vignette"] }],
|
|
246
|
+
})
|
|
247
|
+
expect(parsed.scenes[0]!.effects).toEqual(["grain", "vignette"])
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it("keeps compositing OUT of effects — that is where the phantom slot came from", () => {
|
|
251
|
+
// A field for "there is an inset of a person here" would hand a legitimate
|
|
252
|
+
// home to the invented `{slot:creator} overlay talking to camera`. An effect
|
|
253
|
+
// is verifiable in the pixels; a claim about who is inset is not.
|
|
254
|
+
for (const bad of ["picture-in-picture", "split-screen", "overlay"]) {
|
|
255
|
+
expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene, effects: [bad] }] }).success).toBe(false)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("transitions distinguish DISSOLVE from FADE — they look nothing alike", () => {
|
|
260
|
+
// Collapsed onto `fade` before this, so a cross-dissolve was rendered as a
|
|
261
|
+
// fade through black.
|
|
262
|
+
expect(VIDEO_ANALYSIS_TRANSITIONS).toContain("dissolve")
|
|
263
|
+
for (const t of VIDEO_ANALYSIS_TRANSITIONS) {
|
|
264
|
+
expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene, transitionOut: t }] }).success).toBe(true)
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it("effects and transitions are NOT the same axis", () => {
|
|
269
|
+
// `dissolve`/`fade` are edits BETWEEN shots; blur/pixelate are on the picture.
|
|
270
|
+
for (const t of ["dissolve", "fade", "wipe", "cut"]) {
|
|
271
|
+
expect(VIDEO_ANALYSIS_VISUAL_EFFECTS).not.toContain(t)
|
|
272
|
+
}
|
|
273
|
+
for (const e of VIDEO_ANALYSIS_VISUAL_EFFECTS) {
|
|
274
|
+
expect(VIDEO_ANALYSIS_TRANSITIONS).not.toContain(e as never)
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it("marks the viewpoints where no face is visible — auto-cast reads this", () => {
|
|
279
|
+
// A reference frame shot from behind cannot cast a face, however well framed.
|
|
280
|
+
expect([...VIDEO_ANALYSIS_FACELESS_ANGLES].sort()).toEqual(["from-behind", "over-the-shoulder"])
|
|
281
|
+
for (const a of VIDEO_ANALYSIS_FACELESS_ANGLES) {
|
|
282
|
+
expect(VIDEO_ANALYSIS_SHOT_ANGLES).toContain(a) // never a stale literal
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it("rejects free-text angle — improvising it is the failure being fixed", () => {
|
|
287
|
+
// The shipped defect: `"camera": "low angle static"`, because angle had no
|
|
288
|
+
// field. A free-text `angle` would just move the improvisation.
|
|
289
|
+
expect(windowAnalysisSchema.safeParse({
|
|
290
|
+
slots: [slot], scenes: [{ ...baseScene, angle: "low angle static" }],
|
|
291
|
+
}).success).toBe(false)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it("has NO 'normal' speed member — absence is normal, so there is one way to say it", () => {
|
|
295
|
+
expect(VIDEO_ANALYSIS_SPEED_EFFECTS).not.toContain("normal")
|
|
296
|
+
expect(windowAnalysisSchema.safeParse({
|
|
297
|
+
slots: [slot], scenes: [{ ...baseScene, speed: "normal" }],
|
|
298
|
+
}).success).toBe(false)
|
|
299
|
+
// …and omitting it is valid.
|
|
300
|
+
expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [baseScene] }).success).toBe(true)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it("keeps every new field OPTIONAL so an older producer still validates", () => {
|
|
304
|
+
const parsed = windowAnalysisSchema.parse({ slots: [slot], scenes: [baseScene] })
|
|
305
|
+
expect(parsed.scenes[0]).not.toHaveProperty("angle")
|
|
306
|
+
expect(parsed.scenes[0]).not.toHaveProperty("speed")
|
|
307
|
+
expect(parsed.scenes[0]).not.toHaveProperty("onScreenText")
|
|
308
|
+
expect(parsed).not.toHaveProperty("look")
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it("the new scene fields reach the RESULT schema, not just the window one", () => {
|
|
312
|
+
// analyzedSceneSchema extends windowSceneBase — this pins that it stays that
|
|
313
|
+
// way, since a field the merged result drops is invisible to every consumer.
|
|
314
|
+
const scene = { ...baseScene, sceneNumber: 1, visualResolved: "x", slotRefs: [], angle: "dutch", speed: "freeze", onScreenText: "THE END" }
|
|
315
|
+
expect(analyzedSceneSchema.parse(scene)).toMatchObject({ angle: "dutch", speed: "freeze", onScreenText: "THE END" })
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it("look is a SIBLING of meta, not inside it — meta is measured, look is read", () => {
|
|
319
|
+
const r = videoAnalysisResultSchema.parse({
|
|
320
|
+
meta: { durationSec: 10, width: 1920, height: 1080, aspectRatio: "16:9" },
|
|
321
|
+
look: { grade: "muted teal", format: "anamorphic digital", genre: "cinematic trailer" },
|
|
322
|
+
slots: [],
|
|
323
|
+
scenes: [{ ...baseScene, sceneNumber: 1, visualResolved: "x", slotRefs: [] }],
|
|
324
|
+
})
|
|
325
|
+
expect(r.look?.format).toBe("anamorphic digital")
|
|
326
|
+
expect(r.meta).not.toHaveProperty("look")
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
describe("mergeClipLook", () => {
|
|
331
|
+
it("takes the first non-empty value PER FIELD, not the first window wholesale", () => {
|
|
332
|
+
// Windows see different footage: one may read the grade while only a later
|
|
333
|
+
// one contains the shot that reveals the format.
|
|
334
|
+
expect(mergeClipLook([
|
|
335
|
+
{ grade: "muted teal" },
|
|
336
|
+
{ grade: "warm", format: "16mm film grain" },
|
|
337
|
+
{ lens: "anamorphic flare" },
|
|
338
|
+
])).toEqual({ grade: "muted teal", format: "16mm film grain", lens: "anamorphic flare" })
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it("ignores blank strings and trims what it keeps", () => {
|
|
342
|
+
expect(mergeClipLook([{ grade: " " }, { grade: " crushed blacks " }])).toEqual({ grade: "crushed blacks" })
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it("returns undefined when nothing was read — never an empty object", () => {
|
|
346
|
+
expect(mergeClipLook([])).toBeUndefined()
|
|
347
|
+
expect(mergeClipLook([undefined, {}, { grade: "" }])).toBeUndefined()
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
219
351
|
describe("misc", () => {
|
|
220
352
|
it("isOversizedScene flags > 8s only", () => {
|
|
221
353
|
expect(isOversizedScene(0, 8)).toBe(false)
|
package/src/index.ts
CHANGED
|
@@ -277,6 +277,12 @@ export {
|
|
|
277
277
|
getLlmModalityCaps,
|
|
278
278
|
buildLlmCreditIdentifier,
|
|
279
279
|
resolveLlmCreditId,
|
|
280
|
+
supportsAdvancedMode,
|
|
281
|
+
availableReasoningEfforts,
|
|
282
|
+
LLM_ROUTE_DEFAULTS,
|
|
283
|
+
llmRouteDefaults,
|
|
284
|
+
type LlmRouteDefaults,
|
|
285
|
+
ADVANCED_MODE_UNAVAILABLE_REASON,
|
|
280
286
|
motionGraphicsFeature,
|
|
281
287
|
effectiveReasoningEffort,
|
|
282
288
|
type LlmTier,
|