@nodaro/shared 1.10.0 → 1.12.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.10.0",
3
+ "version": "1.12.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",
@@ -313,31 +313,31 @@ describe("buildVideoCreditModelIdentifier", () => {
313
313
  // --- Duration tiers for kling-3.0 ---
314
314
  describe("kling-3.0 duration tiers", () => {
315
315
  it("5s duration returns :5s tier", () => {
316
- expect(buildVideoCreditModelIdentifier("kling-3.0", 5)).toBe("kling-3.0:5s")
316
+ expect(buildVideoCreditModelIdentifier("kling-3.0", 5, false)).toBe("kling-3.0:5s")
317
317
  })
318
318
 
319
319
  it("3s duration falls into 5s tier", () => {
320
- expect(buildVideoCreditModelIdentifier("kling-3.0", 3)).toBe("kling-3.0:5s")
320
+ expect(buildVideoCreditModelIdentifier("kling-3.0", 3, false)).toBe("kling-3.0:5s")
321
321
  })
322
322
 
323
323
  it("10s duration returns :10s tier", () => {
324
- expect(buildVideoCreditModelIdentifier("kling-3.0", 10)).toBe("kling-3.0:10s")
324
+ expect(buildVideoCreditModelIdentifier("kling-3.0", 10, false)).toBe("kling-3.0:10s")
325
325
  })
326
326
 
327
327
  it("7s duration falls into 10s tier", () => {
328
- expect(buildVideoCreditModelIdentifier("kling-3.0", 7)).toBe("kling-3.0:10s")
328
+ expect(buildVideoCreditModelIdentifier("kling-3.0", 7, false)).toBe("kling-3.0:10s")
329
329
  })
330
330
 
331
331
  it("15s duration returns :15s tier", () => {
332
- expect(buildVideoCreditModelIdentifier("kling-3.0", 15)).toBe("kling-3.0:15s")
332
+ expect(buildVideoCreditModelIdentifier("kling-3.0", 15, false)).toBe("kling-3.0:15s")
333
333
  })
334
334
 
335
335
  it("duration exceeding max tier clamps to last tier (20s -> 15s)", () => {
336
- expect(buildVideoCreditModelIdentifier("kling-3.0", 20)).toBe("kling-3.0:15s")
336
+ expect(buildVideoCreditModelIdentifier("kling-3.0", 20, false)).toBe("kling-3.0:15s")
337
337
  })
338
338
 
339
339
  it("duration exceeding max tier clamps to last tier (100s -> 15s)", () => {
340
- expect(buildVideoCreditModelIdentifier("kling-3.0", 100)).toBe("kling-3.0:15s")
340
+ expect(buildVideoCreditModelIdentifier("kling-3.0", 100, false)).toBe("kling-3.0:15s")
341
341
  })
342
342
  })
343
343
 
@@ -351,6 +351,18 @@ describe("buildVideoCreditModelIdentifier", () => {
351
351
  expect(buildVideoCreditModelIdentifier("kling", 5, true)).toBe("kling:5s:audio")
352
352
  })
353
353
 
354
+ it("kling-3.0 + sound UNDEFINED appends :audio (model default is audio ON)", () => {
355
+ // The provider layer generates audio when the caller expressed no intent
356
+ // (models.ts extraParams.sound: true + kling3-client `?? true`). Billing
357
+ // must mirror that via capability defaultOn — reserving the no-audio
358
+ // tier against an audio-on generation was a silent under-bill.
359
+ expect(buildVideoCreditModelIdentifier("kling-3.0", 5, undefined)).toBe("kling-3.0:5s:audio")
360
+ })
361
+
362
+ it("kling (2.6) + sound UNDEFINED does not append :audio (model default is OFF)", () => {
363
+ expect(buildVideoCreditModelIdentifier("kling", 5, undefined)).toBe("kling:5s")
364
+ })
365
+
354
366
  it("kling-3.0 + sound=false does not append :audio", () => {
355
367
  expect(buildVideoCreditModelIdentifier("kling-3.0", 5, false)).toBe("kling-3.0:5s")
356
368
  })
@@ -407,7 +419,7 @@ describe("buildVideoCreditModelIdentifier", () => {
407
419
  // --- String duration parsing ---
408
420
  describe("duration parsing", () => {
409
421
  it('string duration "10" is parsed as number 10', () => {
410
- expect(buildVideoCreditModelIdentifier("kling-3.0", "10")).toBe("kling-3.0:10s")
422
+ expect(buildVideoCreditModelIdentifier("kling-3.0", "10", false)).toBe("kling-3.0:10s")
411
423
  })
412
424
 
413
425
  it('string duration "5" works', () => {
@@ -415,19 +427,19 @@ describe("buildVideoCreditModelIdentifier", () => {
415
427
  })
416
428
 
417
429
  it("NaN duration defaults to 5", () => {
418
- expect(buildVideoCreditModelIdentifier("kling-3.0", "abc")).toBe("kling-3.0:5s")
430
+ expect(buildVideoCreditModelIdentifier("kling-3.0", "abc", false)).toBe("kling-3.0:5s")
419
431
  })
420
432
 
421
433
  it("undefined duration defaults to 5", () => {
422
- expect(buildVideoCreditModelIdentifier("kling-3.0")).toBe("kling-3.0:5s")
434
+ expect(buildVideoCreditModelIdentifier("kling-3.0", undefined, false)).toBe("kling-3.0:5s")
423
435
  })
424
436
 
425
437
  it("0 duration falls into first tier", () => {
426
- expect(buildVideoCreditModelIdentifier("kling-3.0", 0)).toBe("kling-3.0:5s")
438
+ expect(buildVideoCreditModelIdentifier("kling-3.0", 0, false)).toBe("kling-3.0:5s")
427
439
  })
428
440
 
429
441
  it("1 duration falls into first tier", () => {
430
- expect(buildVideoCreditModelIdentifier("kling-3.0", 1)).toBe("kling-3.0:5s")
442
+ expect(buildVideoCreditModelIdentifier("kling-3.0", 1, false)).toBe("kling-3.0:5s")
431
443
  })
432
444
  })
433
445
 
@@ -7,6 +7,7 @@ import {
7
7
  } from "../video-analysis-pricing.js"
8
8
  import {
9
9
  VIDEO_ANALYSIS_LLM_MODELS, VIDEO_ANALYSIS_TIERS, VIDEO_ANALYSIS_TIER_ORDER,
10
+ VIDEO_ANALYSIS_MIXED_TIERS,
10
11
  DEFAULT_VIDEO_ANALYSIS_TIER, DEFAULT_VIDEO_ANALYSIS_MODEL, resolveVideoAnalysisModel,
11
12
  } from "../llm-models.js"
12
13
 
@@ -43,26 +44,51 @@ describe("video-analysis-pricing", () => {
43
44
  expect(VIDEO_ANALYSIS_LLM_MODELS).toEqual(["gemini-3-flash", "gemini-3.1-pro"])
44
45
  })
45
46
 
46
- it("tier layer: every tier maps to a real model AND every model is tier-reachable (no vendor leak)", () => {
47
+ it("tier layer: every model-backed tier maps to a real model AND every model is tier-reachable (no vendor leak)", () => {
47
48
  // Adding a video-analysis model without a tier would silently leave it
48
49
  // unreachable / unnamed — this fails until a tier decision is made.
49
50
  const tierTargets = Object.values(VIDEO_ANALYSIS_TIERS)
50
51
  for (const m of tierTargets) expect(VIDEO_ANALYSIS_LLM_MODELS).toContain(m)
51
52
  for (const m of VIDEO_ANALYSIS_LLM_MODELS) expect(tierTargets).toContain(m)
52
- expect(new Set(VIDEO_ANALYSIS_TIER_ORDER)).toEqual(new Set(Object.keys(VIDEO_ANALYSIS_TIERS)))
53
+ // TIER_ORDER = model-backed tiers + mixed roll-plan tiers, exactly.
54
+ expect(new Set(VIDEO_ANALYSIS_TIER_ORDER)).toEqual(
55
+ new Set([...Object.keys(VIDEO_ANALYSIS_TIERS), ...VIDEO_ANALYSIS_MIXED_TIERS]),
56
+ )
57
+ // Mixed tiers are SENTINELS, never model ids — a mixed id leaking into the
58
+ // model list would break the roll-plan dispatch in the analysis engine.
59
+ for (const t of VIDEO_ANALYSIS_MIXED_TIERS) expect(VIDEO_ANALYSIS_LLM_MODELS).not.toContain(t)
53
60
  })
54
61
 
55
- it("resolveVideoAnalysisModel: tier → model, raw model passthrough, default pro on empty/unknown", () => {
62
+ it("resolveVideoAnalysisModel: tier → model, mixed → sentinel, raw model passthrough, default pro on empty/unknown", () => {
56
63
  expect(DEFAULT_VIDEO_ANALYSIS_TIER).toBe("pro")
57
64
  expect(DEFAULT_VIDEO_ANALYSIS_MODEL).toBe("gemini-3.1-pro")
58
65
  expect(resolveVideoAnalysisModel("pro")).toBe("gemini-3.1-pro")
59
66
  expect(resolveVideoAnalysisModel("fast")).toBe("gemini-3-flash")
67
+ expect(resolveVideoAnalysisModel("mixed")).toBe("mixed") // roll-plan sentinel passthrough
68
+ expect(resolveVideoAnalysisModel("mixed-fast")).toBe("mixed-fast")
60
69
  expect(resolveVideoAnalysisModel("gemini-3-flash")).toBe("gemini-3-flash") // raw passthrough
61
70
  expect(resolveVideoAnalysisModel(undefined)).toBe("gemini-3.1-pro") // default → pro
62
71
  expect(resolveVideoAnalysisModel("")).toBe("gemini-3.1-pro")
63
72
  expect(resolveVideoAnalysisModel("nonsense")).toBe("gemini-3.1-pro") // unknown → default, never throws
64
73
  })
65
74
 
75
+ it("mixed tiers price under ONE shared credit family (video-analysis:mixed:*)", () => {
76
+ // Both variants are the identical compute plan — a per-variant price split
77
+ // would be a phantom distinction and double the admin surface.
78
+ for (const bucketSec of VIDEO_ANALYSIS_DURATION_BUCKETS) {
79
+ expect(buildVideoAnalysisCreditId("mixed", bucketSec)).toBe(`video-analysis:mixed:${bucketSec}s`)
80
+ expect(buildVideoAnalysisCreditId("mixed-fast", bucketSec)).toBe(`video-analysis:mixed:${bucketSec}s`)
81
+ const credits = VIDEO_ANALYSIS_BUCKET_CREDITS[`video-analysis:mixed:${bucketSec}s`]
82
+ expect(credits, `missing mixed entry for ${bucketSec}s`).toBeDefined()
83
+ expect(Number.isInteger(credits)).toBe(true)
84
+ // Sanity: mixed (3 fast + 2 pro rolls + refine) must never price below
85
+ // the pro tier it supersets.
86
+ expect(credits).toBeGreaterThanOrEqual(
87
+ VIDEO_ANALYSIS_BUCKET_CREDITS[`video-analysis:gemini-3.1-pro:${bucketSec}s`],
88
+ )
89
+ }
90
+ })
91
+
66
92
  // Full drift-detection against the live $-formula lives in
67
93
  // backend/src/lib/pricing/__tests__/video-analysis-cost.test.ts (this
68
94
  // package cannot see the formula post-S5). This is a lightweight shape
@@ -22,15 +22,33 @@ describe("getVideoAudioCapability", () => {
22
22
  }
23
23
  })
24
24
 
25
- it("returns ambient (sound toggle, cost-affecting) for Kling", () => {
25
+ it("returns native_speech (sound toggle, cost-affecting) for KIE Kling", () => {
26
+ // Probe-verified 2026-07-16: scripted quoted dialogue comes back verbatim
27
+ // with articulated lips on the KIE path for BOTH kling (2.6) and kling-3.0.
26
28
  for (const m of ["kling", "kling-3.0"]) {
27
29
  const cap = getVideoAudioCapability(m)
28
- expect(cap.mode, m).toBe("ambient")
30
+ expect(cap.mode, m).toBe("native_speech")
29
31
  expect(cap.field, m).toBe("sound")
30
32
  expect(cap.affectsCost, m).toBe(true)
31
33
  }
32
34
  })
33
35
 
36
+ it("returns native_speech (generateAudio lever, flat-priced) for kling-3-omni", () => {
37
+ const cap = getVideoAudioCapability("kling-3-omni")
38
+ expect(cap.mode).toBe("native_speech")
39
+ expect(cap.field).toBe("generateAudio")
40
+ // Audio is priced into the Replicate flat per-duration rate — no :audio composite.
41
+ expect(cap.affectsCost).toBeUndefined()
42
+ expect(cap.defaultOn).toBe(true)
43
+ })
44
+
45
+ it("defaultOn mirrors each model's own config default", () => {
46
+ // kling-3.0 generates audio unless explicitly disabled (models.ts
47
+ // extraParams.sound: true + kling3-client `?? true`); kling 2.6 defaults off.
48
+ expect(getVideoAudioCapability("kling-3.0").defaultOn).toBe(true)
49
+ expect(getVideoAudioCapability("kling").defaultOn).toBeUndefined()
50
+ })
51
+
34
52
  it("returns audio_driven for Seedance 2.0", () => {
35
53
  for (const m of ["seedance-2", "seedance-2-fast"]) {
36
54
  const cap = getVideoAudioCapability(m)
@@ -66,6 +84,7 @@ describe("videoModelSupportsAudio", () => {
66
84
  it("is true for any model with an audio mode, false for silent", () => {
67
85
  expect(videoModelSupportsAudio("veo3")).toBe(true)
68
86
  expect(videoModelSupportsAudio("kling-3.0")).toBe(true)
87
+ expect(videoModelSupportsAudio("kling-3-omni")).toBe(true)
69
88
  expect(videoModelSupportsAudio("seedance-2")).toBe(true)
70
89
  expect(videoModelSupportsAudio("seedance")).toBe(true)
71
90
  expect(videoModelSupportsAudio("minimax")).toBe(false)
@@ -80,9 +99,12 @@ describe("videoModelCanSpeakDialogue", () => {
80
99
  expect(videoModelCanSpeakDialogue("seedance-2")).toBe(true)
81
100
  expect(videoModelCanSpeakDialogue("seedance-2-fast")).toBe(true)
82
101
  expect(videoModelCanSpeakDialogue("seedance-2-mini")).toBe(true)
102
+ // Kling 2.6 / 3.0 / Omni speak scripted dialogue natively (probe-verified
103
+ // 2026-07-16) — the Story→Video auto-pick uses in-model speech + revoice.
104
+ expect(videoModelCanSpeakDialogue("kling")).toBe(true)
105
+ expect(videoModelCanSpeakDialogue("kling-3.0")).toBe(true)
106
+ expect(videoModelCanSpeakDialogue("kling-3-omni")).toBe(true)
83
107
  // ambient-only models are NOT dialogue-capable — their audio is SFX/ambient
84
- expect(videoModelCanSpeakDialogue("kling")).toBe(false)
85
- expect(videoModelCanSpeakDialogue("kling-3.0")).toBe(false)
86
108
  expect(videoModelCanSpeakDialogue("seedance")).toBe(false)
87
109
  expect(videoModelCanSpeakDialogue("minimax")).toBe(false)
88
110
  expect(videoModelCanSpeakDialogue(undefined)).toBe(false)
@@ -101,10 +123,16 @@ describe("seedance-2-mini is a full Seedance 2 family member", () => {
101
123
  })
102
124
 
103
125
  describe("VIDEO_AUDIO_CAPABILITY internal consistency", () => {
104
- it("every Kling AUDIO_ADDON provider is ambient + affectsCost", () => {
126
+ it("every AUDIO_ADDON provider is cost-affecting on the canonical `sound` lever", () => {
127
+ // The billing-critical invariant: the `:audio` surcharge keys off `sound`,
128
+ // so every surcharged model must declare audio, be marked cost-affecting,
129
+ // and carry its toggle on that exact field (applyVideoAudioToggle then
130
+ // refuses the generateAudio alias for these, making a billed/generated
131
+ // divergence structurally impossible).
105
132
  for (const m of AUDIO_ADDON_PROVIDERS) {
106
133
  const cap = getVideoAudioCapability(m)
107
- expect(cap.mode, m).toBe("ambient")
134
+ expect(cap.mode, m).not.toBe("none")
135
+ expect(cap.field, m).toBe("sound")
108
136
  expect(cap.affectsCost, m).toBe(true)
109
137
  }
110
138
  })
@@ -18,6 +18,7 @@ import {
18
18
  MOTION_DURATION_TIERS,
19
19
  T2I_TO_I2I_VARIANT,
20
20
  isVeoProvider,
21
+ getVideoAudioCapability,
21
22
  } from "./model-constants.js"
22
23
  import { isFlux2Model } from "./flux2-pricing.js"
23
24
  import { MODEL_CATALOG } from "./model-catalog.js"
@@ -227,8 +228,13 @@ export function buildVideoCreditModelIdentifier(
227
228
  const tier = tiers.find(t => durationSec <= t.maxSeconds) ?? tiers[tiers.length - 1]
228
229
  let identifier = `${effectiveProvider}:${tier.suffix}`
229
230
 
230
- // Append audio suffix if applicable
231
- if (AUDIO_ADDON_PROVIDERS.has(effectiveProvider) && sound) {
231
+ // Append audio suffix if applicable. When the caller expressed no intent
232
+ // (sound === undefined), fall back to the model's own default (capability
233
+ // `defaultOn` — kling-3.0 generates audio unless explicitly disabled), so an
234
+ // intent-less request is billed for the audio it actually produces instead
235
+ // of reserving the cheaper no-audio tier against an audio-on generation.
236
+ const soundOn = sound ?? (getVideoAudioCapability(effectiveProvider).defaultOn === true)
237
+ if (AUDIO_ADDON_PROVIDERS.has(effectiveProvider) && soundOn) {
232
238
  identifier += ":audio"
233
239
  }
234
240
 
package/src/index.ts CHANGED
@@ -255,11 +255,15 @@ export {
255
255
  VIDEO_ANALYSIS_LLM_MODELS,
256
256
  VIDEO_ANALYSIS_TIERS,
257
257
  type VideoAnalysisTier,
258
+ type VideoAnalysisModelTier,
259
+ VIDEO_ANALYSIS_MIXED_TIERS,
260
+ type VideoAnalysisMixedTier,
258
261
  VIDEO_ANALYSIS_TIER_ORDER,
259
262
  DEFAULT_VIDEO_ANALYSIS_TIER,
260
263
  DEFAULT_VIDEO_ANALYSIS_MODEL,
261
264
  VIDEO_ANALYSIS_TIER_LABELS,
262
265
  isVideoAnalysisTier,
266
+ isVideoAnalysisMixedTier,
263
267
  resolveVideoAnalysisModel,
264
268
  LLM_FEATURE_DEFAULTS,
265
269
  LLM_MODALITY_CAPS,
package/src/llm-models.ts CHANGED
@@ -396,26 +396,53 @@ export const VIDEO_ANALYSIS_LLM_MODELS: string[] = LLM_MODELS
396
396
  * so adding a video model forces a tier decision instead of silently leaking.
397
397
  */
398
398
  export const VIDEO_ANALYSIS_TIERS = { fast: "gemini-3-flash", pro: "gemini-3.1-pro" } as const
399
- export type VideoAnalysisTier = keyof typeof VIDEO_ANALYSIS_TIERS
399
+ export type VideoAnalysisModelTier = keyof typeof VIDEO_ANALYSIS_TIERS
400
+ /**
401
+ * MIXED tiers — advanced multi-engine analysis plans whose identifier resolves
402
+ * to an engine-plan SENTINEL consumed by the analysis engine, never to a single
403
+ * model id. Two variants, same price (one shared `video-analysis:mixed:*`
404
+ * credit family): `mixed` targets maximum result quality; `mixed-fast` targets
405
+ * run-to-run output consistency. What each plan does internally is deliberately
406
+ * NOT published here (Apache irrevocability; only the wire vocabulary below is
407
+ * contract — the engine lives in the private analysis plugin).
408
+ */
409
+ export const VIDEO_ANALYSIS_MIXED_TIERS = ["mixed", "mixed-fast"] as const
410
+ export type VideoAnalysisMixedTier = (typeof VIDEO_ANALYSIS_MIXED_TIERS)[number]
400
411
  /** UI/listing order — recommended (pro) first. */
401
- export const VIDEO_ANALYSIS_TIER_ORDER = ["pro", "fast"] as const
412
+ export const VIDEO_ANALYSIS_TIER_ORDER = ["pro", "fast", "mixed", "mixed-fast"] as const
413
+ export type VideoAnalysisTier = (typeof VIDEO_ANALYSIS_TIER_ORDER)[number]
402
414
  export const DEFAULT_VIDEO_ANALYSIS_TIER: VideoAnalysisTier = "pro"
403
415
  export const DEFAULT_VIDEO_ANALYSIS_MODEL: string = VIDEO_ANALYSIS_TIERS[DEFAULT_VIDEO_ANALYSIS_TIER]
404
416
  /** Neutral, vendor-free display labels for the UI. */
405
- export const VIDEO_ANALYSIS_TIER_LABELS: Record<VideoAnalysisTier, string> = { fast: "Fast", pro: "Pro" }
417
+ export const VIDEO_ANALYSIS_TIER_LABELS: Record<VideoAnalysisTier, string> = {
418
+ fast: "Fast",
419
+ pro: "Pro",
420
+ mixed: "Mixed",
421
+ "mixed-fast": "Mixed (consistent)",
422
+ }
406
423
 
407
424
  export function isVideoAnalysisTier(v: string): v is VideoAnalysisTier {
408
- return Object.prototype.hasOwnProperty.call(VIDEO_ANALYSIS_TIERS, v)
425
+ return (VIDEO_ANALYSIS_TIER_ORDER as readonly string[]).includes(v)
426
+ }
427
+
428
+ export function isVideoAnalysisMixedTier(v: string): v is VideoAnalysisMixedTier {
429
+ return (VIDEO_ANALYSIS_MIXED_TIERS as readonly string[]).includes(v)
409
430
  }
410
431
 
411
432
  /**
412
- * Resolve a user-supplied tier (`"fast"`/`"pro"`) OR a raw internal model id to
413
- * the internal analysis model id. Empty/unknown → the default tier's model.
414
- * Real model ids pass through (back-compat for existing stored `llmModel`
415
- * values); anything else falls back to the default rather than erroring.
433
+ * Resolve a user-supplied tier OR a raw internal model id to the analysis
434
+ * ENGINE IDENTIFIER carried in the worker payload:
435
+ * - model-backed tiers ("fast"/"pro") the internal model id;
436
+ * - mixed tiers ("mixed"/"mixed-fast") the sentinel ITSELF (the engine
437
+ * expands it to a multi-model roll plan);
438
+ * - raw model ids pass through (back-compat for stored `llmModel` values);
439
+ * - empty/unknown → the default tier's model (never an error).
416
440
  */
417
441
  export function resolveVideoAnalysisModel(input?: string | null): string {
418
- if (input && isVideoAnalysisTier(input)) return VIDEO_ANALYSIS_TIERS[input]
442
+ if (input && isVideoAnalysisMixedTier(input)) return input
443
+ if (input && Object.prototype.hasOwnProperty.call(VIDEO_ANALYSIS_TIERS, input)) {
444
+ return VIDEO_ANALYSIS_TIERS[input as VideoAnalysisModelTier]
445
+ }
419
446
  if (input && VIDEO_ANALYSIS_LLM_MODELS.includes(input)) return input
420
447
  return DEFAULT_VIDEO_ANALYSIS_MODEL
421
448
  }
@@ -1839,6 +1839,26 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
1839
1839
  { identifier: "video-analysis:gemini-3.1-pro:600s", credits: 11, note: "10-min ceiling" },
1840
1840
  ],
1841
1841
  },
1842
+ // Both mixed tiers are variants of the same advanced multi-engine analysis
1843
+ // and share this ONE credit family (videoAnalysisCreditSegment maps both;
1844
+ // plan internals live in the private analysis plugin).
1845
+ "mixed-video-analysis": {
1846
+ id: "mixed-video-analysis",
1847
+ kind: "video",
1848
+ modes: ["video-analysis"] as const,
1849
+ family: "Nodaro",
1850
+ label: "Video Analysis (Mixed)",
1851
+ series: "Video Analysis",
1852
+ description: "Our most advanced analysis tier — multiple analysis engines combined into one result for maximum completeness and accuracy. Billed per duration bucket.",
1853
+ useCases: ["video-analysis", "shot-list", "premium", "most-complete"],
1854
+ pricing: [
1855
+ { identifier: "video-analysis:mixed", credits: 14, note: "10-min ceiling (no duration given)" },
1856
+ { identifier: "video-analysis:mixed:60s", credits: 3 },
1857
+ { identifier: "video-analysis:mixed:180s", credits: 4 },
1858
+ { identifier: "video-analysis:mixed:360s", credits: 9 },
1859
+ { identifier: "video-analysis:mixed:600s", credits: 14, note: "10-min ceiling" },
1860
+ ],
1861
+ },
1842
1862
  }
1843
1863
 
1844
1864
  // =============================================================================
@@ -1393,8 +1393,13 @@ export const AUDIO_ADDON_PROVIDERS = new Set([
1393
1393
  * lip-synced spoken dialogue. Toggle is offered; the
1394
1394
  * pipeline still uses TTS + lip-sync for dialogue.
1395
1395
  * "native_speech" — bakes spoken dialogue + lip movement from the prompt
1396
- * (VEO 3.x). The pipeline injects the dialogue line, enables
1397
- * audio, and revoices the clip to the character's saved voice.
1396
+ * (VEO 3.x always-on; Kling 2.6/3.0 behind the `sound`
1397
+ * toggle probe-verified 2026-07-16: scripted lines come
1398
+ * back word-for-word with articulated lips on the KIE
1399
+ * path, matching the official Kling 2.6 audio guide and
1400
+ * Kling 3.0 prompting docs). The pipeline injects the
1401
+ * dialogue line, enables audio, and revoices the clip to
1402
+ * the character's saved voice.
1398
1403
  * "audio_driven" — lip-syncs to a supplied reference-audio track (Seedance
1399
1404
  * 2.0 multimodal). The pipeline synthesises the character's
1400
1405
  * voice first, feeds it as reference audio, and skips the
@@ -1410,6 +1415,14 @@ export interface VideoAudioCapability {
1410
1415
  alwaysOn?: boolean
1411
1416
  /** Enabling audio raises the credit cost (Kling — see AUDIO_ADDON_PROVIDERS). */
1412
1417
  affectsCost?: boolean
1418
+ /**
1419
+ * The model generates audio when the caller expresses NO intent (its own
1420
+ * config default is on — kling-3.0's `sound: true`, kling-3-omni's
1421
+ * `generate_audio: true`). The `:audio` credit suffix mirrors this default
1422
+ * so an intent-less request is billed for the audio it actually produces
1423
+ * (see buildVideoCreditModelIdentifier). Absent ⇒ default off.
1424
+ */
1425
+ defaultOn?: boolean
1413
1426
  }
1414
1427
 
1415
1428
  /**
@@ -1426,9 +1439,22 @@ export const VIDEO_AUDIO_CAPABILITY: Record<string, VideoAudioCapability> = {
1426
1439
  veo3: { mode: "native_speech", alwaysOn: true },
1427
1440
  "veo3.1": { mode: "native_speech", alwaysOn: true },
1428
1441
  veo3_lite: { mode: "native_speech", alwaysOn: true },
1429
- // Kling 2.6 / 3.0 — ambient sound/SFX toggle; not lip-synced speech. Cost-affecting.
1430
- kling: { mode: "ambient", field: "sound", affectsCost: true },
1431
- "kling-3.0": { mode: "ambient", field: "sound", affectsCost: true },
1442
+ // Kling 2.6 / 3.0 — native spoken dialogue + lip sync behind the `sound`
1443
+ // toggle (probe-verified on the KIE path 2026-07-16: scripted quoted lines
1444
+ // are spoken verbatim with articulated lips; the official Kling 2.6 audio
1445
+ // guide documents speech/dialogue/narration/singing with [Character@Voice]
1446
+ // binding, zh+en voices). Cost-affecting: `:audio` credit suffix.
1447
+ // kling-3.0's model default is sound ON (kie models.ts extraParams +
1448
+ // kling3-client `?? true`) → defaultOn keeps billing aligned with what an
1449
+ // intent-less request actually generates; kling 2.6 defaults OFF.
1450
+ kling: { mode: "native_speech", field: "sound", affectsCost: true },
1451
+ "kling-3.0": { mode: "native_speech", field: "sound", affectsCost: true, defaultOn: true },
1452
+ // Kling 3.0 Omni (Replicate kwaivgi/kling-v3-omni-video) — the Omni tier's
1453
+ // headline feature IS native dialogue (per-character voices, unified audio
1454
+ // timeline). Lever is Replicate's `generate_audio` (default true in our
1455
+ // provider config → defaultOn); audio is priced into the flat per-duration
1456
+ // rate, so NOT cost-affecting (no :audio composite).
1457
+ "kling-3-omni": { mode: "native_speech", field: "generateAudio", defaultOn: true },
1432
1458
  // Seedance 1.x — optional ambient audio (generate_audio); not dialogue.
1433
1459
  seedance: { mode: "ambient", field: "generateAudio" },
1434
1460
  // Seedance 2.0 — multimodal; lip-syncs to a supplied reference-audio track.
@@ -1457,11 +1483,11 @@ export function videoModelSupportsAudio(model: string | undefined): boolean {
1457
1483
 
1458
1484
  /**
1459
1485
  * True when the model can produce lip-synced spoken DIALOGUE — either natively
1460
- * (VEO) or driven by a supplied audio track (Seedance 2.0). Drives the Story→Video
1461
- * dialogue auto-pick: in-model speech + character revoice (VEO) / character-voiced
1462
- * reference audio (Seedance 2.0) vs. the TTS + separate-lip-sync fallback.
1463
- * Ambient-only models (Kling, Seedance 1.x) return `false` — their audio is SFX,
1464
- * not speech.
1486
+ * (VEO 3.x, Kling 2.6/3.0/Omni) or driven by a supplied audio track (Seedance
1487
+ * 2.0). Drives the Story→Video dialogue auto-pick: in-model speech + character
1488
+ * revoice (VEO, Kling) / character-voiced reference audio (Seedance 2.0) vs.
1489
+ * the TTS + separate-lip-sync fallback. Ambient-only models (Seedance 1.x)
1490
+ * return `false` — their audio is SFX, not speech.
1465
1491
  */
1466
1492
  export function videoModelCanSpeakDialogue(model: string | undefined): boolean {
1467
1493
  const mode = getVideoAudioCapability(model).mode
@@ -54,6 +54,24 @@ export const VIDEO_ANALYSIS_BUCKET_CREDITS: Record<string, number> = {
54
54
  "video-analysis:gemini-3.1-pro:180s": 3,
55
55
  "video-analysis:gemini-3.1-pro:360s": 7,
56
56
  "video-analysis:gemini-3.1-pro:600s": 11,
57
+ // Mixed tiers (`mixed` + `mixed-fast`) share ONE credit family — they are
58
+ // variants of the same engine plan (plan internals live in the private
59
+ // analysis plugin). Admin-tunable via model_pricing like every other row.
60
+ "video-analysis:mixed:60s": 3,
61
+ "video-analysis:mixed:180s": 4,
62
+ "video-analysis:mixed:360s": 9,
63
+ "video-analysis:mixed:600s": 14,
64
+ }
65
+
66
+ /**
67
+ * The credit-id MODEL SEGMENT for an engine identifier: both mixed-tier
68
+ * sentinels share the `mixed` price family (same engine plan); everything
69
+ * else prices under its own identifier. Single source of truth — used by
70
+ * `buildVideoAnalysisCreditId` below, so route/orchestrator/UI callers can
71
+ * never diverge on where a sentinel prices.
72
+ */
73
+ export function videoAnalysisCreditSegment(modelOrSentinel: string): string {
74
+ return modelOrSentinel === "mixed-fast" ? "mixed" : modelOrSentinel
57
75
  }
58
76
 
59
77
  export function pickVideoAnalysisBucket(durationSec: number): number {
@@ -65,7 +83,7 @@ export function buildVideoAnalysisCreditId(model: string, durationSec?: number):
65
83
  const bucket = durationSec !== undefined && durationSec > 0
66
84
  ? pickVideoAnalysisBucket(Math.min(durationSec, VIDEO_ANALYSIS_MAX_DURATION_SEC))
67
85
  : VIDEO_ANALYSIS_MAX_DURATION_SEC // unknown → ceiling composite (the ONLY silent-ceiling path)
68
- return `video-analysis:${model}:${bucket}s`
86
+ return `video-analysis:${videoAnalysisCreditSegment(model)}:${bucket}s`
69
87
  }
70
88
 
71
89
  export function bucketSecondsFromCreditId(creditId: string): number | null {