@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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
- // backend/src/lib/pricing/__tests__/video-analysis-cost.test.ts (this
118
- // package cannot see the formula post-S5). This is a lightweight shape
119
- // check that the precomputed table covers every legal id.
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,