@nodaro/shared 1.21.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 +219 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +319 -15
- package/dist/index.d.ts +319 -15
- package/dist/index.js +210 -50
- 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 +133 -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 +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,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,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,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,
|
package/src/llm-models.ts
CHANGED
|
@@ -51,8 +51,44 @@ export interface LlmModelDef {
|
|
|
51
51
|
structuredOutputMode?: "anthropic-tool" | "kie-response-format" | "responses-json-schema"
|
|
52
52
|
/** If set, fallback to direct Anthropic SDK with this model ID when KIE.ai fails */
|
|
53
53
|
directFallbackModel?: string
|
|
54
|
+
/**
|
|
55
|
+
* Google Gemini API model id for the DIRECT lane (generativelanguage, keyed
|
|
56
|
+
* by `GEMINI_API_KEY`) — the Google-side twin of `directFallbackModel`.
|
|
57
|
+
* Presence declares "this model CAN be served straight from Google"; absence
|
|
58
|
+
* pins it to KIE forever. Stated, never derived: Google carries `-preview`
|
|
59
|
+
* suffixes on unreleased models, so the id routinely differs from both `id`
|
|
60
|
+
* and `kieSlugOrModel` (`gemini-3.1-pro` → `gemini-3.1-pro-preview`).
|
|
61
|
+
*/
|
|
62
|
+
directGeminiModel?: string
|
|
63
|
+
/**
|
|
64
|
+
* Try the direct-vendor lane FIRST for this model, with KIE as the failure
|
|
65
|
+
* fallback. Absent (while `directGeminiModel` is set) = KIE first, direct
|
|
66
|
+
* only when KIE fails.
|
|
67
|
+
*
|
|
68
|
+
* This is a per-model COST decision, not just a routing one: the two lanes
|
|
69
|
+
* bill the same model at materially different unit rates, so a model that
|
|
70
|
+
* backs a high-volume default (see `LLM_FEATURE_DEFAULTS`) is usually better
|
|
71
|
+
* left on whichever lane is cheaper. The rate tables for both lanes live in
|
|
72
|
+
* `backend/src/lib/pricing/llm-cost.ts` — deliberately not in this package,
|
|
73
|
+
* which is published to npm.
|
|
74
|
+
*
|
|
75
|
+
* Mutually exclusive with `preferKie` — the Claude-side half of the same
|
|
76
|
+
* idea. Guarded by a registry test so the two can't both be set.
|
|
77
|
+
*/
|
|
78
|
+
preferDirect?: true
|
|
54
79
|
/** Effort levels this model accepts (ascending). Absent/empty = no effort lever, picker hidden. */
|
|
55
80
|
reasoningEfforts?: readonly LlmReasoningEffort[]
|
|
81
|
+
/**
|
|
82
|
+
* Effort levels available on the DIRECT lane, when the vendor's own API
|
|
83
|
+
* accepts more than the aggregator does. Absent = the direct lane offers the
|
|
84
|
+
* same set as `reasoningEfforts`.
|
|
85
|
+
*
|
|
86
|
+
* This exists because `reasoningEfforts` has to stay at the KIE-safe
|
|
87
|
+
* intersection — sending a level KIE rejects is a hard failure — while the
|
|
88
|
+
* vendor API accepts the full ladder. Unlocking those extra levels is one of
|
|
89
|
+
* the concrete things Advanced mode buys.
|
|
90
|
+
*/
|
|
91
|
+
directReasoningEfforts?: readonly LlmReasoningEffort[]
|
|
56
92
|
/** false = model rejects `temperature` (Claude 5-era, GPT-5.6). Absent = accepts. */
|
|
57
93
|
supportsTemperature?: false
|
|
58
94
|
/** Claude-only: KIE is the preferred routing, direct Anthropic the fallback. */
|
|
@@ -85,6 +121,13 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
|
|
|
85
121
|
structuredOutputMode: "kie-response-format",
|
|
86
122
|
supportsImages: true,
|
|
87
123
|
maxOutputTokens: 8192,
|
|
124
|
+
// KIE-first: no `preferDirect` — direct is the reliability fallback only.
|
|
125
|
+
// (Per-lane rates are deliberately NOT in this published package; see
|
|
126
|
+
// backend/src/lib/pricing/llm-cost.ts.)
|
|
127
|
+
directGeminiModel: "gemini-3-flash-preview",
|
|
128
|
+
// No `reasoningEfforts` at all on the KIE lane, but the vendor API accepts
|
|
129
|
+
// the full minimal→high ladder (`none` maps to Google's `minimal`).
|
|
130
|
+
directReasoningEfforts: ["none", "low", "medium", "high"],
|
|
88
131
|
},
|
|
89
132
|
{
|
|
90
133
|
id: "gemini-3.6-flash",
|
|
@@ -102,7 +145,19 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
|
|
|
102
145
|
maxOutputTokens: 8192,
|
|
103
146
|
// KIE's 3.6 endpoint accepts `reasoning_effort: low | high` (thinking
|
|
104
147
|
// level) — exactly the chat-completions wire mapping deriveParams sends.
|
|
148
|
+
// Google's own API additionally accepts `minimal` and `medium` on this
|
|
149
|
+
// model; the set stays at the KIE-safe intersection because ONE field
|
|
150
|
+
// feeds both lanes and this model is KIE-first. Widen it only if/when
|
|
151
|
+
// `preferDirect` is set here.
|
|
105
152
|
reasoningEfforts: ["low", "high"],
|
|
153
|
+
// Google's own API additionally accepts `minimal` and `medium` here —
|
|
154
|
+
// live-verified 2026-07-28. Advanced mode unlocks them.
|
|
155
|
+
directReasoningEfforts: ["none", "low", "medium", "high"],
|
|
156
|
+
// KIE-first: this model backs 5 of the LLM_FEATURE_DEFAULTS plus the
|
|
157
|
+
// video-analysis fast tier, so it carries the highest call volume of any
|
|
158
|
+
// Gemini entry — the lane with the lower unit cost wins by default and
|
|
159
|
+
// direct is the reliability fallback only.
|
|
160
|
+
directGeminiModel: "gemini-3.6-flash",
|
|
106
161
|
},
|
|
107
162
|
{
|
|
108
163
|
id: "claude-haiku-4.5",
|
|
@@ -153,6 +208,18 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
|
|
|
153
208
|
structuredOutputMode: "kie-response-format",
|
|
154
209
|
supportsImages: true,
|
|
155
210
|
maxOutputTokens: 16384,
|
|
211
|
+
directGeminiModel: "gemini-3.1-pro-preview",
|
|
212
|
+
// Google documents low/medium/high for 3.1 Pro — no `minimal` tier, so
|
|
213
|
+
// this ladder is deliberately shorter than the flash models'.
|
|
214
|
+
directReasoningEfforts: ["low", "medium", "high"],
|
|
215
|
+
// The ONE Gemini model routed direct-first. It is the premium/low-volume
|
|
216
|
+
// tier (video-analysis `pro`, no LLM_FEATURE_DEFAULTS entry), so the ~4×
|
|
217
|
+
// list-price premium lands on the smallest call volume — and it is where
|
|
218
|
+
// the direct lane's capability wins actually matter: real `thinkingLevel`
|
|
219
|
+
// control, native media ingestion, and a `responseJsonSchema` that honours
|
|
220
|
+
// `additionalProperties` (KIE's `response_format` silently DROPS
|
|
221
|
+
// record/map-shaped fields — see the z.record rule in backend/CLAUDE.md).
|
|
222
|
+
preferDirect: true,
|
|
156
223
|
},
|
|
157
224
|
{
|
|
158
225
|
id: "claude-opus-4.7",
|
|
@@ -425,21 +492,45 @@ export function getLlmModel(id: string): LlmModelDef | undefined {
|
|
|
425
492
|
if (aliased) return aliased
|
|
426
493
|
}
|
|
427
494
|
// Last resort: provider slugs double as historical aliases (e.g. the
|
|
428
|
-
// dated Anthropic slugs) — accept any
|
|
429
|
-
|
|
495
|
+
// dated Anthropic slugs, the `-preview`-suffixed Google ids) — accept any
|
|
496
|
+
// model whose slug matches exactly, on either lane.
|
|
497
|
+
return LLM_MODELS.find(
|
|
498
|
+
(m) => m.kieSlugOrModel === id || m.directFallbackModel === id || m.directGeminiModel === id,
|
|
499
|
+
)
|
|
430
500
|
}
|
|
431
501
|
|
|
432
502
|
export function getLlmTier(id: string): LlmTier {
|
|
433
503
|
return getLlmModel(id)?.tier ?? "standard"
|
|
434
504
|
}
|
|
435
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Effort levels this model actually accepts on the lane it will be served on.
|
|
508
|
+
*
|
|
509
|
+
* The two lanes do NOT offer the same ladder: the aggregator accepts a narrower
|
|
510
|
+
* set than the vendor's own API does, which is one of the concrete things
|
|
511
|
+
* Advanced mode buys. Kept as one lookup so the UI picker and the wire-side
|
|
512
|
+
* clamp can never disagree about what's selectable.
|
|
513
|
+
*/
|
|
514
|
+
export function availableReasoningEfforts(
|
|
515
|
+
modelId: string | undefined,
|
|
516
|
+
advanced = false,
|
|
517
|
+
): readonly LlmReasoningEffort[] {
|
|
518
|
+
const model = getLlmModel(modelId ?? "")
|
|
519
|
+
if (!model) return []
|
|
520
|
+
if (advanced && supportsAdvancedMode(modelId)) {
|
|
521
|
+
return model.directReasoningEfforts ?? model.reasoningEfforts ?? []
|
|
522
|
+
}
|
|
523
|
+
return model.reasoningEfforts ?? []
|
|
524
|
+
}
|
|
525
|
+
|
|
436
526
|
/** Highest level the model supports that is ≤ the requested level; undefined = treat as Auto. */
|
|
437
527
|
export function effectiveReasoningEffort(
|
|
438
528
|
modelId: string | undefined,
|
|
439
529
|
requested?: string,
|
|
530
|
+
advanced = false,
|
|
440
531
|
): LlmReasoningEffort | undefined {
|
|
441
532
|
if (!requested || !(requested in EFFORT_RANK)) return undefined
|
|
442
|
-
const levels =
|
|
533
|
+
const levels = availableReasoningEfforts(modelId, advanced)
|
|
443
534
|
if (!levels || levels.length === 0) return undefined
|
|
444
535
|
const req = requested as LlmReasoningEffort
|
|
445
536
|
let best: LlmReasoningEffort | undefined
|
|
@@ -459,25 +550,64 @@ export function effectiveReasoningEffort(
|
|
|
459
550
|
* premium stays premium). `high` is the Claude-family server default and
|
|
460
551
|
* never bumps.
|
|
461
552
|
*/
|
|
462
|
-
|
|
553
|
+
/** One step up the economy → standard → premium ladder. Premium is the ceiling. */
|
|
554
|
+
function bumpTier(tier: LlmTier): LlmTier {
|
|
555
|
+
if (tier === "economy") return "standard"
|
|
556
|
+
if (tier === "standard") return "premium"
|
|
557
|
+
return tier
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Can this model be run in Advanced mode?
|
|
562
|
+
*
|
|
563
|
+
* Advanced mode pins the call to the vendor's own API, which is the only lane
|
|
564
|
+
* where sampling levers (`temperature`, `maxTokens`) and the full effort range
|
|
565
|
+
* actually take effect. Capability-derived from the registry — a model without
|
|
566
|
+
* a direct lane simply cannot offer it, so UI and routes both gate on this
|
|
567
|
+
* rather than on a hand-maintained model list.
|
|
568
|
+
*/
|
|
569
|
+
export function supportsAdvancedMode(modelId: string | undefined): boolean {
|
|
570
|
+
return Boolean(modelId && getLlmModel(modelId)?.directGeminiModel)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** User-facing reason a model can't offer Advanced mode. Single-sourced so the
|
|
574
|
+
* config panel's disabled hint and the route's 400 say the same thing. */
|
|
575
|
+
export const ADVANCED_MODE_UNAVAILABLE_REASON =
|
|
576
|
+
"Advanced mode is available on Gemini models — switch the model to enable it."
|
|
577
|
+
|
|
578
|
+
export function buildLlmCreditIdentifier(
|
|
579
|
+
feature: string,
|
|
580
|
+
modelId?: string,
|
|
581
|
+
reasoningEffort?: string,
|
|
582
|
+
advancedMode?: boolean,
|
|
583
|
+
): string {
|
|
463
584
|
if (!modelId) return feature
|
|
464
585
|
let tier = getLlmTier(modelId)
|
|
465
586
|
const eff = effectiveReasoningEffort(modelId, reasoningEffort)
|
|
466
|
-
if (eff !== undefined && EFFORT_TIER_BUMP.has(eff))
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
587
|
+
if (eff !== undefined && EFFORT_TIER_BUMP.has(eff)) tier = bumpTier(tier)
|
|
588
|
+
// Advanced mode routes to the vendor's own API, which bills materially more
|
|
589
|
+
// per token than the aggregator. It bumps INDEPENDENTLY of the effort bump —
|
|
590
|
+
// the two are separate cost levers and genuinely stack, so a max-effort
|
|
591
|
+
// advanced economy call lands at premium. The bump is ignored on a model that
|
|
592
|
+
// can't run advanced at all, so a stale flag never inflates a bill.
|
|
593
|
+
if (advancedMode && supportsAdvancedMode(modelId)) tier = bumpTier(tier)
|
|
470
594
|
if (tier === "standard") return feature
|
|
471
595
|
return `${feature}:${tier}`
|
|
472
596
|
}
|
|
473
597
|
|
|
474
598
|
/**
|
|
475
|
-
* Resolve llmModel (+ reasoningEffort) from raw body for
|
|
476
|
-
* (before Zod parsing). Returns the credit identifier
|
|
599
|
+
* Resolve llmModel (+ reasoningEffort, advancedMode) from raw body for the
|
|
600
|
+
* creditGuard preHandler (before Zod parsing). Returns the credit identifier
|
|
601
|
+
* for the given feature.
|
|
477
602
|
*/
|
|
478
603
|
export function resolveLlmCreditId(feature: string, body: unknown): string {
|
|
479
604
|
const b = body as Record<string, unknown> | undefined
|
|
480
|
-
return buildLlmCreditIdentifier(
|
|
605
|
+
return buildLlmCreditIdentifier(
|
|
606
|
+
feature,
|
|
607
|
+
b?.llmModel as string | undefined,
|
|
608
|
+
b?.reasoningEffort as string | undefined,
|
|
609
|
+
b?.advancedMode === true,
|
|
610
|
+
)
|
|
481
611
|
}
|
|
482
612
|
|
|
483
613
|
/** Models capable of video-analysis: capability-derived, never hand-listed (route-enum-sync convention). */
|