@nodaro/shared 1.20.0 → 1.22.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 +261 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +350 -15
- package/dist/index.d.ts +350 -15
- package/dist/index.js +250 -51
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/llm-models.test.ts +138 -0
- package/src/__tests__/video-analysis-catalog-sync.test.ts +63 -0
- package/src/__tests__/video-analysis-pricing.test.ts +5 -4
- package/src/__tests__/video-analysis.test.ts +201 -1
- package/src/index.ts +3 -0
- package/src/llm-models.ts +141 -11
- package/src/model-catalog.ts +20 -20
- package/src/video-analysis-pricing.ts +21 -18
- package/src/video-analysis.ts +256 -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,139 @@ 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("never bumps past premium", () => {
|
|
682
|
+
expect(buildLlmCreditIdentifier("llm-chat", "gemini-3.1-pro", "max", true)).toBe("llm-chat:premium")
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it("ignores the flag on a model that cannot run advanced (no silent overcharge)", () => {
|
|
686
|
+
// gpt-5.2 has no direct lane — a stale advancedMode flag must not inflate it.
|
|
687
|
+
expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.2", undefined, true))
|
|
688
|
+
.toBe(buildLlmCreditIdentifier("llm-chat", "gpt-5.2"))
|
|
689
|
+
expect(buildLlmCreditIdentifier("llm-chat", "claude-opus-4.7", undefined, true))
|
|
690
|
+
.toBe(buildLlmCreditIdentifier("llm-chat", "claude-opus-4.7"))
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it("back-compat: omitting the 4th arg is identical to before for every model", () => {
|
|
694
|
+
for (const m of LLM_MODELS) {
|
|
695
|
+
expect(buildLlmCreditIdentifier("x", m.id, "high", false)).toBe(buildLlmCreditIdentifier("x", m.id, "high"))
|
|
696
|
+
}
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
it("resolveLlmCreditId reads advancedMode from the raw body", () => {
|
|
700
|
+
expect(resolveLlmCreditId("llm-chat", { llmModel: "gemini-3-flash", advancedMode: true })).toBe("llm-chat")
|
|
701
|
+
expect(resolveLlmCreditId("llm-chat", { llmModel: "gemini-3-flash" })).toBe("llm-chat:economy")
|
|
702
|
+
// Only a real boolean true counts — a truthy string must not bump.
|
|
703
|
+
expect(resolveLlmCreditId("llm-chat", { llmModel: "gemini-3-flash", advancedMode: "yes" })).toBe("llm-chat:economy")
|
|
704
|
+
})
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
describe("lane-aware reasoning efforts", () => {
|
|
708
|
+
it("widens the ladder on the direct lane where the vendor accepts more", () => {
|
|
709
|
+
expect(availableReasoningEfforts("gemini-3.6-flash")).toEqual(["low", "high"])
|
|
710
|
+
expect(availableReasoningEfforts("gemini-3.6-flash", true)).toEqual(["none", "low", "medium", "high"])
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
it("exposes an effort lever on models that have none via KIE", () => {
|
|
714
|
+
// gemini-3-flash declares no reasoningEfforts at all — Advanced is the
|
|
715
|
+
// only way its effort picker appears.
|
|
716
|
+
expect(availableReasoningEfforts("gemini-3-flash")).toEqual([])
|
|
717
|
+
expect(availableReasoningEfforts("gemini-3-flash", true)).toEqual(["none", "low", "medium", "high"])
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it("respects a shorter direct ladder (3.1 Pro has no minimal tier)", () => {
|
|
721
|
+
expect(availableReasoningEfforts("gemini-3.1-pro", true)).toEqual(["low", "medium", "high"])
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
it("ignores the advanced flag on a model with no direct lane", () => {
|
|
725
|
+
const claude = availableReasoningEfforts("claude-sonnet-4.6")
|
|
726
|
+
expect(availableReasoningEfforts("claude-sonnet-4.6", true)).toEqual(claude)
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
it("clamps against the lane's ladder, not the other lane's", () => {
|
|
730
|
+
// `medium` is not on 3.6-flash's KIE ladder → clamps down to `low`.
|
|
731
|
+
expect(effectiveReasoningEffort("gemini-3.6-flash", "medium")).toBe("low")
|
|
732
|
+
// On the direct lane `medium` is a real level → survives.
|
|
733
|
+
expect(effectiveReasoningEffort("gemini-3.6-flash", "medium", true)).toBe("medium")
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
it("every directReasoningEfforts entry is a valid effort, ascending", () => {
|
|
737
|
+
for (const m of LLM_MODELS.filter((x) => x.directReasoningEfforts)) {
|
|
738
|
+
const levels = m.directReasoningEfforts!
|
|
739
|
+
for (const l of levels) expect(LLM_REASONING_EFFORTS, m.id).toContain(l)
|
|
740
|
+
const ranks = levels.map((l) => LLM_REASONING_EFFORTS.indexOf(l))
|
|
741
|
+
expect(ranks, `${m.id} must be ascending`).toEqual([...ranks].sort((a, b) => a - b))
|
|
742
|
+
}
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
it("only advanced-capable models declare a direct ladder", () => {
|
|
746
|
+
const bad = LLM_MODELS.filter((m) => m.directReasoningEfforts && !supportsAdvancedMode(m.id)).map((m) => m.id)
|
|
747
|
+
expect(bad).toEqual([])
|
|
748
|
+
})
|
|
749
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
} from "../video-analysis-pricing.js"
|
|
17
|
+
|
|
18
|
+
/** Every `video-analysis:*` pricing row declared anywhere in the catalog. */
|
|
19
|
+
const catalogRows = Object.values(MODEL_CATALOG)
|
|
20
|
+
.flatMap((m) => (m.pricing ?? []) as ReadonlyArray<{ identifier: string; credits: number }>)
|
|
21
|
+
.filter((r) => r.identifier.startsWith("video-analysis:"))
|
|
22
|
+
|
|
23
|
+
describe("model catalog video-analysis pricing", () => {
|
|
24
|
+
it("declares at least one row (guard is actually wired to something)", () => {
|
|
25
|
+
expect(catalogRows.length).toBeGreaterThan(0)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("every bucketed catalog row matches VIDEO_ANALYSIS_BUCKET_CREDITS exactly", () => {
|
|
29
|
+
const bucketed = catalogRows.filter((r) => /:\d+s$/.test(r.identifier))
|
|
30
|
+
expect(bucketed.length).toBeGreaterThan(0)
|
|
31
|
+
for (const row of bucketed) {
|
|
32
|
+
expect(
|
|
33
|
+
row.credits,
|
|
34
|
+
`catalog "${row.identifier}" = ${row.credits} but the table says ${VIDEO_ANALYSIS_BUCKET_CREDITS[row.identifier]}`,
|
|
35
|
+
).toBe(VIDEO_ANALYSIS_BUCKET_CREDITS[row.identifier])
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("every bare (no-duration) catalog row equals its max-duration ceiling", () => {
|
|
40
|
+
// A bare id means "duration unknown", which the route prices at the ceiling.
|
|
41
|
+
const bare = catalogRows.filter((r) => !/:\d+s$/.test(r.identifier))
|
|
42
|
+
expect(bare.length).toBeGreaterThan(0)
|
|
43
|
+
for (const row of bare) {
|
|
44
|
+
const ceiling = VIDEO_ANALYSIS_BUCKET_CREDITS[`${row.identifier}:${VIDEO_ANALYSIS_MAX_DURATION_SEC}s`]
|
|
45
|
+
expect(ceiling, `no ceiling row for ${row.identifier}`).toBeDefined()
|
|
46
|
+
expect(row.credits, `catalog "${row.identifier}" must equal its ${VIDEO_ANALYSIS_MAX_DURATION_SEC}s ceiling`).toBe(ceiling)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("catalog covers every bucket it claims a model supports", () => {
|
|
51
|
+
const byModel = new Map<string, Set<number>>()
|
|
52
|
+
for (const r of catalogRows) {
|
|
53
|
+
const m = /^video-analysis:(.+):(\d+)s$/.exec(r.identifier)
|
|
54
|
+
if (!m) continue
|
|
55
|
+
if (!byModel.has(m[1]!)) byModel.set(m[1]!, new Set())
|
|
56
|
+
byModel.get(m[1]!)!.add(Number(m[2]))
|
|
57
|
+
}
|
|
58
|
+
for (const [model, buckets] of byModel) {
|
|
59
|
+
expect([...buckets].sort((a, b) => a - b), `${model} bucket coverage`)
|
|
60
|
+
.toEqual([...VIDEO_ANALYSIS_DURATION_BUCKETS])
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -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,8 +5,11 @@ import {
|
|
|
5
5
|
renderAnalyzedScene, isOversizedScene, aspectRatioFromDims,
|
|
6
6
|
entitySlotSchema, analyzedSceneSchema,
|
|
7
7
|
rewriteSceneBindings, dropUnknownBindings,
|
|
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,
|
|
8
11
|
VIDEO_ANALYSIS_MAX_VARIATIONS, VIDEO_ANALYSIS_VARIATION_SLUGS, VIDEO_ANALYSIS_DEFAULT_VARIATION,
|
|
9
|
-
type EntitySlot,
|
|
12
|
+
type EntitySlot, type AudioLayer,
|
|
10
13
|
} from "../video-analysis.js"
|
|
11
14
|
|
|
12
15
|
const slot: EntitySlot = { slotId: "hero", label: "Protagonist", source: "wired-character", role: "person", description: "tan man, mustache, black tee" }
|
|
@@ -148,6 +151,203 @@ describe("binding rewrite helpers (merge consumes — spec §4)", () => {
|
|
|
148
151
|
})
|
|
149
152
|
})
|
|
150
153
|
|
|
154
|
+
describe("speech attribution (speakerSlot)", () => {
|
|
155
|
+
const speech = (content: string, over: Partial<AudioLayer> = {}): AudioLayer => ({ mode: "speech", content, ...over })
|
|
156
|
+
|
|
157
|
+
it("rides on speech layers and survives a window round-trip", () => {
|
|
158
|
+
const parsed = windowAnalysisSchema.parse({
|
|
159
|
+
slots: [slot],
|
|
160
|
+
scenes: [{ ...baseScene, audio: [speech("As a kid…", { voice: "male, warm", speakerSlot: "hero" })] }],
|
|
161
|
+
})
|
|
162
|
+
expect(parsed.scenes[0]!.audio[0]!.speakerSlot).toBe("hero")
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("is NOT refined against mode — a mis-tagged music layer must not fail the whole roll", () => {
|
|
166
|
+
// The window schema IS the enforced decode grammar. Rejecting here would
|
|
167
|
+
// throw away every scene in a window over one stray field; the sanitizer
|
|
168
|
+
// below strips it instead.
|
|
169
|
+
expect(windowAnalysisSchema.safeParse({
|
|
170
|
+
slots: [slot],
|
|
171
|
+
scenes: [{ ...baseScene, audio: [{ mode: "music", content: "synth bed", speakerSlot: "hero" }] }],
|
|
172
|
+
}).success).toBe(true)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("rewriteSpeakerSlots follows a slot through cross-window unification", () => {
|
|
176
|
+
// Slot unification renames the loser id and rewrites {slot:…} tokens and
|
|
177
|
+
// variation bindings; attribution has to move with them or it dangles.
|
|
178
|
+
const audio = [speech("hi", { speakerSlot: "man-2" }), speech("ho", { speakerSlot: "other" })]
|
|
179
|
+
expect(rewriteSpeakerSlots(audio, { "man-2": "hero" }).map((a) => a.speakerSlot)).toEqual(["hero", "other"])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("rewriteSpeakerSlots is copy-on-write when no layer names a renamed slot", () => {
|
|
183
|
+
const audio = [speech("hi", { speakerSlot: "hero" }), { mode: "music" as const, content: "bed" }]
|
|
184
|
+
expect(rewriteSpeakerSlots(audio, { ghost: "other" })).toBe(audio)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("dropUnknownSpeakers strips attribution to a slot that no longer exists", () => {
|
|
188
|
+
const r = dropUnknownSpeakers([speech("hi", { speakerSlot: "ghost" })], new Set(["hero"]))
|
|
189
|
+
expect(r.audio[0]).not.toHaveProperty("speakerSlot")
|
|
190
|
+
expect(r.dropped).toEqual(["ghost"])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("dropUnknownSpeakers strips attribution from music/sfx — nobody is speaking", () => {
|
|
194
|
+
const r = dropUnknownSpeakers(
|
|
195
|
+
[{ mode: "sfx", content: "door slam", speakerSlot: "hero" }],
|
|
196
|
+
new Set(["hero"]),
|
|
197
|
+
)
|
|
198
|
+
expect(r.audio[0]).not.toHaveProperty("speakerSlot")
|
|
199
|
+
expect(r.dropped).toEqual(["hero"])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("dropUnknownSpeakers keeps a valid speaker and every other layer field", () => {
|
|
203
|
+
const audio = [speech("As a kid…", { voice: "male, warm", speakerSlot: "hero" })]
|
|
204
|
+
const r = dropUnknownSpeakers(audio, new Set(["hero"]))
|
|
205
|
+
expect(r.audio).toBe(audio) // copy-on-write: untouched input returned as-is
|
|
206
|
+
expect(r.dropped).toEqual([])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("attribution alone must NOT keep a slot alive — that is the phantom narrator", () => {
|
|
210
|
+
// A slot referenced only as a speaker is a voice with no body (doctrine §5).
|
|
211
|
+
// deriveSlotRefs reads {slot:…} tokens from `visual` ONLY, so an
|
|
212
|
+
// attribution-only slot stays invisible to the reference sweep and gets
|
|
213
|
+
// dropped — then dropUnknownSpeakers removes the dangling attribution.
|
|
214
|
+
expect(deriveSlotRefs("a lunar plain, no one in frame")).toEqual([])
|
|
215
|
+
const r = dropUnknownSpeakers([speech("that's me!", { speakerSlot: "creator" })], new Set())
|
|
216
|
+
expect(r.audio[0]).not.toHaveProperty("speakerSlot")
|
|
217
|
+
expect(r.dropped).toEqual(["creator"])
|
|
218
|
+
})
|
|
219
|
+
})
|
|
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
|
+
|
|
151
351
|
describe("misc", () => {
|
|
152
352
|
it("isOversizedScene flags > 8s only", () => {
|
|
153
353
|
expect(isOversizedScene(0, 8)).toBe(false)
|
package/src/index.ts
CHANGED
|
@@ -277,6 +277,9 @@ export {
|
|
|
277
277
|
getLlmModalityCaps,
|
|
278
278
|
buildLlmCreditIdentifier,
|
|
279
279
|
resolveLlmCreditId,
|
|
280
|
+
supportsAdvancedMode,
|
|
281
|
+
availableReasoningEfforts,
|
|
282
|
+
ADVANCED_MODE_UNAVAILABLE_REASON,
|
|
280
283
|
motionGraphicsFeature,
|
|
281
284
|
effectiveReasoningEffort,
|
|
282
285
|
type LlmTier,
|