@nodaro/shared 2.1.0 → 2.2.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.
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { MODEL_CATALOG } from "../model-catalog.js"
3
+ import {
4
+ FRAME_MODE_ADAPTIVE_ONLY_ASPECT,
5
+ MAX_VIDEO_PROMPT_CHARS_BY_PROVIDER,
6
+ NATIVE_ADAPTIVE_ASPECT,
7
+ PRICING_DEFAULT_RESOLUTION,
8
+ PROMPT_HARD_CEILING,
9
+ SEEDANCE_2_5_REF_LIMITS,
10
+ SEEDANCE_2_PROVIDERS,
11
+ VIDEO_DURATION_TIERS,
12
+ VIDEO_REF_LIMITS_BY_PROVIDER,
13
+ getMaxVideoPromptChars,
14
+ isSeedance2Provider,
15
+ seedance2AudioLimitSec,
16
+ } from "../model-constants.js"
17
+ import { buildVideoCreditModelIdentifier } from "../credit-identifiers.js"
18
+
19
+ const ID = "seedance-2-5"
20
+
21
+ /**
22
+ * Seedance 2.5's shape was established by a live capability probe against
23
+ * api.kie.ai on 2026-08-08, NOT by the published schema — KIE's docs advertise
24
+ * a wider surface than the proxy actually accepts. These tests pin the probed
25
+ * reality so a future "the spec sheet says 4K/180s" edit has to re-probe first.
26
+ */
27
+ describe("seedance-2-5 catalog (probe-verified 2026-08-08)", () => {
28
+ it("is 480p/720p ONLY — KIE rejects 1080p/4k/2k the same way it rejects a nonsense value", () => {
29
+ expect(MODEL_CATALOG[ID].resolutions).toEqual(["480p", "720p"])
30
+ })
31
+
32
+ it("runs 4-30s contiguously — 30 is the probed ceiling (31 was rejected)", () => {
33
+ const durations = MODEL_CATALOG[ID].durations
34
+ expect(durations?.[0]).toBe(4)
35
+ expect(durations?.[durations.length - 1]).toBe(30)
36
+ expect(durations).toHaveLength(27)
37
+ })
38
+
39
+ it("carries the wider 30/10/10 reference caps, NOT the 2.0 family's 9/3/3", () => {
40
+ expect(VIDEO_REF_LIMITS_BY_PROVIDER[ID]).toEqual({ images: 30, videos: 10, audio: 10 })
41
+ expect(SEEDANCE_2_5_REF_LIMITS.images).toBeGreaterThan(
42
+ VIDEO_REF_LIMITS_BY_PROVIDER["seedance-2"]!.images!,
43
+ )
44
+ })
45
+
46
+ it("joins the Seedance 2 capability family (adaptive aspect, audio, refs)", () => {
47
+ expect(isSeedance2Provider(ID)).toBe(true)
48
+ expect(SEEDANCE_2_PROVIDERS.has(ID)).toBe(true)
49
+ expect(NATIVE_ADAPTIVE_ASPECT[ID]).toBe("adaptive")
50
+ expect(MODEL_CATALOG[ID].aspectRatios).toContain("adaptive")
51
+ expect(MODEL_CATALOG[ID].aspectRatios).toContain("21:9")
52
+ })
53
+
54
+ it("forces adaptive aspect in frame mode — the 2.0 SKUs deliberately do NOT", () => {
55
+ expect(FRAME_MODE_ADAPTIVE_ONLY_ASPECT.has(ID)).toBe(true)
56
+ for (const p of ["seedance-2", "seedance-2-fast", "seedance-2-mini"]) {
57
+ expect(FRAME_MODE_ADAPTIVE_ONLY_ASPECT.has(p)).toBe(false)
58
+ }
59
+ })
60
+
61
+ it("accepts a 30000-char prompt, and the route ceiling is wide enough to pass it through", () => {
62
+ expect(getMaxVideoPromptChars(ID)).toBe(30000)
63
+ // The generous route ceiling must never sit BELOW a real per-model cap, or
64
+ // Zod hard-rejects a prompt the model would have accepted.
65
+ const caps = Object.values(MAX_VIDEO_PROMPT_CHARS_BY_PROVIDER)
66
+ expect(PROMPT_HARD_CEILING).toBeGreaterThanOrEqual(Math.max(...caps))
67
+ })
68
+
69
+ it("enforces the documented 30s reference-audio cap", () => {
70
+ expect(seedance2AudioLimitSec(ID)).toBe(30)
71
+ })
72
+ })
73
+
74
+ describe("seedance-2-5 pricing identifiers", () => {
75
+ const build = (duration?: number, resolution?: string, hasVideoRef?: boolean) =>
76
+ buildVideoCreditModelIdentifier(ID, duration, undefined, undefined, undefined, resolution, hasVideoRef)
77
+
78
+ it("gives every allowed second its OWN tier — no round-up to a coarser rung", () => {
79
+ // The 2.0 family's 4/8/12/15 ladder would price a 23s render at the 15s
80
+ // rung; commit_credits only refunds a surplus, so that gap is permanent.
81
+ expect(VIDEO_DURATION_TIERS[ID]).toHaveLength(27)
82
+ expect(build(23, "720p")).toBe("seedance-2-5:23s:720p")
83
+ expect(build(17, "480p")).toBe("seedance-2-5:17s:480p")
84
+ expect(build(30, "720p")).toBe("seedance-2-5:30s:720p")
85
+ })
86
+
87
+ it("prices an OMITTED resolution at the model's real KIE default (720p), not the cheapest tier", () => {
88
+ // KIE renders 720p when resolution is absent. Falling back to 480p here
89
+ // would reserve the cheap tier against an expensive render.
90
+ expect(PRICING_DEFAULT_RESOLUTION[ID]).toBe("720p")
91
+ expect(build(8)).toBe("seedance-2-5:8s:720p")
92
+ })
93
+
94
+ it("leaves the 2.0 family's omitted-resolution behaviour untouched", () => {
95
+ // Adding PRICING_DEFAULT_RESOLUTION must not silently reprice live models.
96
+ for (const p of ["seedance-2", "seedance-2-fast", "seedance-2-mini"]) {
97
+ expect(PRICING_DEFAULT_RESOLUTION[p]).toBeUndefined()
98
+ expect(
99
+ buildVideoCreditModelIdentifier(p, 8, undefined, undefined, undefined, undefined, false),
100
+ ).toBe(`${p}:8s:480p`)
101
+ }
102
+ })
103
+
104
+ it("clamps an unsupported resolution to the top real tier so the id is always seeded", () => {
105
+ expect(build(8, "1080p")).toBe("seedance-2-5:8s:720p")
106
+ expect(build(8, "4k")).toBe("seedance-2-5:8s:720p")
107
+ })
108
+
109
+ it("selects the cheaper -ref ladder when a reference video is wired", () => {
110
+ expect(build(8, "720p", true)).toBe("seedance-2-5:8s:720p-ref")
111
+ expect(build(8, "480p", true)).toBe("seedance-2-5:8s:480p-ref")
112
+ })
113
+
114
+ it("resolves every catalog duration x resolution x ref-mode to a distinct tier", () => {
115
+ const seen = new Set<string>()
116
+ for (const d of MODEL_CATALOG[ID].durations!) {
117
+ for (const res of ["480p", "720p"]) {
118
+ for (const ref of [false, true]) {
119
+ const identifier = build(d, res, ref)
120
+ expect(identifier).toBe(`${ID}:${d}s:${res}${ref ? "-ref" : ""}`)
121
+ seen.add(identifier)
122
+ }
123
+ }
124
+ }
125
+ // 27 durations x 2 resolutions x 2 ref-modes, none collapsing onto another.
126
+ expect(seen.size).toBe(108)
127
+ })
128
+ })
@@ -14,6 +14,7 @@ import {
14
14
  VIDEO_ANALYSIS_DURATION_BUCKETS,
15
15
  VIDEO_ANALYSIS_MAX_DURATION_SEC,
16
16
  buildVideoAnalysisCreditId,
17
+ VIDEO_AUDIT_BUCKET_CREDITS,
17
18
  } from "../video-analysis-pricing.js"
18
19
  import { DEFAULT_VIDEO_ANALYSIS_MODEL } from "../llm-models.js"
19
20
 
@@ -22,6 +23,18 @@ const catalogRows = Object.values(MODEL_CATALOG)
22
23
  .flatMap((m) => (m.pricing ?? []) as ReadonlyArray<{ identifier: string; credits: number }>)
23
24
  .filter((r) => r.identifier.startsWith("video-analysis:"))
24
25
 
26
+ /**
27
+ * Every `video-audit*` pricing row declared anywhere in the catalog. NOTE:
28
+ * no trailing colon in the filter (unlike the `video-analysis:` filter
29
+ * above) — `video-audit`'s bare base-family id has no colon at all (there's
30
+ * no per-model segment to be bare "within", unlike video-analysis's bare
31
+ * per-model ids which still carry `video-analysis:<model>`), so a
32
+ * colon-anchored filter would silently miss it.
33
+ */
34
+ const auditCatalogRows = Object.values(MODEL_CATALOG)
35
+ .flatMap((m) => (m.pricing ?? []) as ReadonlyArray<{ identifier: string; credits: number }>)
36
+ .filter((r) => r.identifier.startsWith("video-audit"))
37
+
25
38
  describe("model catalog video-analysis pricing", () => {
26
39
  it("declares at least one row (guard is actually wired to something)", () => {
27
40
  expect(catalogRows.length).toBeGreaterThan(0)
@@ -64,6 +77,56 @@ describe("model catalog video-analysis pricing", () => {
64
77
  })
65
78
  })
66
79
 
80
+ /**
81
+ * Same guard as above, for `video-audit` ("AI Audit") — a sibling node whose
82
+ * catalog rows hand-copy `VIDEO_AUDIT_BUCKET_CREDITS`. Mirrors the
83
+ * video-analysis describe block's structure exactly, adapted for
84
+ * video-audit's simpler id shape (two FAMILIES — `video-audit` /
85
+ * `video-audit:auto` — rather than an open set of per-model segments).
86
+ */
87
+ describe("model catalog video-audit pricing", () => {
88
+ it("declares at least one row (guard is actually wired to something)", () => {
89
+ expect(auditCatalogRows.length).toBeGreaterThan(0)
90
+ })
91
+
92
+ it("every bucketed catalog row matches VIDEO_AUDIT_BUCKET_CREDITS exactly", () => {
93
+ const bucketed = auditCatalogRows.filter((r) => /:\d+s$/.test(r.identifier))
94
+ expect(bucketed.length).toBeGreaterThan(0)
95
+ for (const row of bucketed) {
96
+ expect(
97
+ row.credits,
98
+ `catalog "${row.identifier}" = ${row.credits} but the table says ${VIDEO_AUDIT_BUCKET_CREDITS[row.identifier]}`,
99
+ ).toBe(VIDEO_AUDIT_BUCKET_CREDITS[row.identifier])
100
+ }
101
+ })
102
+
103
+ it("every bare (no-duration) catalog row equals its 600s ceiling", () => {
104
+ // A bare id means "duration unknown", which prices at the ceiling.
105
+ const bare = auditCatalogRows.filter((r) => !/:\d+s$/.test(r.identifier))
106
+ expect(bare.length).toBe(2) // exactly `video-audit` + `video-audit:auto`
107
+ for (const row of bare) {
108
+ const ceiling = VIDEO_AUDIT_BUCKET_CREDITS[`${row.identifier}:${VIDEO_ANALYSIS_MAX_DURATION_SEC}s`]
109
+ expect(ceiling, `no ceiling row for ${row.identifier}`).toBeDefined()
110
+ expect(row.credits, `catalog "${row.identifier}" must equal its ${VIDEO_ANALYSIS_MAX_DURATION_SEC}s ceiling`).toBe(ceiling)
111
+ }
112
+ })
113
+
114
+ it("catalog covers every bucket for both families", () => {
115
+ const byFamily = new Map<string, Set<number>>()
116
+ for (const r of auditCatalogRows) {
117
+ const m = /^(video-audit(?::auto)?):(\d+)s$/.exec(r.identifier)
118
+ if (!m) continue
119
+ if (!byFamily.has(m[1]!)) byFamily.set(m[1]!, new Set())
120
+ byFamily.get(m[1]!)!.add(Number(m[2]))
121
+ }
122
+ expect([...byFamily.keys()].sort()).toEqual(["video-audit", "video-audit:auto"])
123
+ for (const [family, buckets] of byFamily) {
124
+ expect([...buckets].sort((a, b) => a - b), `${family} bucket coverage`)
125
+ .toEqual([...VIDEO_ANALYSIS_DURATION_BUCKETS])
126
+ }
127
+ })
128
+ })
129
+
67
130
  /**
68
131
  * The BARE `video-analysis` node-type id — the one every colon-scoped guard misses.
69
132
  *
@@ -90,9 +153,12 @@ describe("bare video-analysis node-type credit id", () => {
90
153
  // 279 wrote 739, 283 wrote 346, 284 wrote 350 — `smart` owns the ceiling and
91
154
  // gained the continuity pass — 288 wrote 3500 as a x10 of that, 293
92
155
  // corrected it to 3496 by RE-DERIVING `smart:600s` from the plugin formula,
93
- // and 294 wrote 1868: the smart re-base measured 6 fps equal-or-better than
94
- // 24 and the schedule regenerated ~47% lower at the ceiling bucket).
95
- expect(ceiling).toBe(1868)
156
+ // 294 wrote 1868: the smart re-base measured 6 fps equal-or-better than
157
+ // 24 and the schedule regenerated ~47% lower at the ceiling bucket, and 300
158
+ // wrote 2064: the V1 hybrid-smart reprice (task A3) moved `smart` to a
159
+ // multi-roll plan (native skeleton + donor rolls, always refined) and
160
+ // trued up every tier's judge/refine terms from staging measurement).
161
+ expect(ceiling).toBe(2064)
96
162
  })
97
163
 
98
164
  it("the bare id still bounds the default tier at the ceiling bucket", () => {
@@ -4,6 +4,8 @@ import {
4
4
  VIDEO_ANALYSIS_BUCKET_CREDITS,
5
5
  pickVideoAnalysisBucket, buildVideoAnalysisCreditId, bucketSecondsFromCreditId,
6
6
  videoAnalysisNumWindows,
7
+ VIDEO_AUDIT_BUCKET_CREDITS,
8
+ buildVideoAuditCreditId, videoAuditCreditsForBucket, bucketSecondsFromAuditCreditId,
7
9
  } from "../video-analysis-pricing.js"
8
10
  import {
9
11
  VIDEO_ANALYSIS_LLM_MODELS, VIDEO_ANALYSIS_TIERS, VIDEO_ANALYSIS_TIER_ORDER,
@@ -135,3 +137,106 @@ describe("video-analysis-pricing", () => {
135
137
  }
136
138
  })
137
139
  })
140
+
141
+ // `video-audit` ("AI Audit") is a SEPARATE node from video-analysis, but
142
+ // shares this module's bucket ladder and generator-authoritative table
143
+ // pattern. Its own $-formula and generator live in the private
144
+ // `@nodaroai/cloud-plugins` package exactly like video-analysis's — see
145
+ // `VIDEO_AUDIT_BUCKET_CREDITS`'s doc comment. These values are pasted
146
+ // verbatim from that generator's output; this file only covers the
147
+ // NON-monetary bucket/id-construction logic, same split as above.
148
+ describe("video-audit-pricing", () => {
149
+ it("buildVideoAuditCreditId: family selection by analysisProvided, same bucket ladder/rounding as buildVideoAnalysisCreditId", () => {
150
+ expect(buildVideoAuditCreditId({ analysisProvided: true, durationSec: 60 })).toBe("video-audit:60s")
151
+ expect(buildVideoAuditCreditId({ analysisProvided: false, durationSec: 60 })).toBe("video-audit:auto:60s")
152
+ expect(buildVideoAuditCreditId({ analysisProvided: true, durationSec: 170 })).toBe("video-audit:180s")
153
+ expect(buildVideoAuditCreditId({ analysisProvided: false, durationSec: 170 })).toBe("video-audit:auto:180s")
154
+ expect(buildVideoAuditCreditId({ analysisProvided: true, durationSec: 360 })).toBe("video-audit:360s")
155
+ expect(buildVideoAuditCreditId({ analysisProvided: true, durationSec: 600 })).toBe("video-audit:600s")
156
+
157
+ // Tolerance/boundary edges: 60 stays in the 60s bucket (inclusive upper
158
+ // bound); 61/63/64 all bump straight to the next bucket (180s) — the
159
+ // builder has NO grace period of its own (identical cliff behavior to
160
+ // pickVideoAnalysisBucket / buildVideoAnalysisCreditId; any tolerance
161
+ // grace is a WORKER re-check concern, private to the plugin, never baked
162
+ // into this pure id builder).
163
+ expect(buildVideoAuditCreditId({ analysisProvided: true, durationSec: 60 })).toBe("video-audit:60s")
164
+ expect(buildVideoAuditCreditId({ analysisProvided: true, durationSec: 61 })).toBe("video-audit:180s")
165
+ expect(buildVideoAuditCreditId({ analysisProvided: true, durationSec: 63 })).toBe("video-audit:180s")
166
+ expect(buildVideoAuditCreditId({ analysisProvided: true, durationSec: 64 })).toBe("video-audit:180s")
167
+ expect(buildVideoAuditCreditId({ analysisProvided: false, durationSec: 61 })).toBe("video-audit:auto:180s")
168
+
169
+ // No / invalid duration → 600s ceiling composite, both families — the
170
+ // ONLY silent-ceiling path, matching buildVideoAnalysisCreditId.
171
+ expect(buildVideoAuditCreditId({ analysisProvided: true })).toBe("video-audit:600s")
172
+ expect(buildVideoAuditCreditId({ analysisProvided: false })).toBe("video-audit:auto:600s")
173
+ expect(buildVideoAuditCreditId({ analysisProvided: true, durationSec: 0 })).toBe("video-audit:600s")
174
+ expect(buildVideoAuditCreditId({ analysisProvided: true, durationSec: -5 })).toBe("video-audit:600s")
175
+ expect(buildVideoAuditCreditId({ analysisProvided: true, durationSec: Number.NaN })).toBe("video-audit:600s")
176
+ // Beyond the ceiling clamps to 600s rather than an out-of-ladder bucket.
177
+ expect(buildVideoAuditCreditId({ analysisProvided: false, durationSec: 9999 })).toBe("video-audit:auto:600s")
178
+ })
179
+
180
+ it("videoAuditCreditsForBucket: table lookup by resolved bucket + family, snapping a raw duration onto the ladder", () => {
181
+ expect(videoAuditCreditsForBucket(60, false)).toBe(213)
182
+ expect(videoAuditCreditsForBucket(60, true)).toBe(393)
183
+ expect(videoAuditCreditsForBucket(180, false)).toBe(289)
184
+ expect(videoAuditCreditsForBucket(360, false)).toBe(659)
185
+ expect(videoAuditCreditsForBucket(600, false)).toBe(1066)
186
+ expect(videoAuditCreditsForBucket(600, true)).toBe(1912)
187
+ // Not just exact ladder values — a raw duration snaps up to its bucket.
188
+ expect(videoAuditCreditsForBucket(70, false)).toBe(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:180s"])
189
+ expect(videoAuditCreditsForBucket(9999, true)).toBe(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:auto:600s"])
190
+ })
191
+
192
+ it("bucketSecondsFromAuditCreditId: round-trips both families, null on bare ids / video-analysis ids / garbage", () => {
193
+ for (const bucketSec of VIDEO_ANALYSIS_DURATION_BUCKETS) {
194
+ expect(bucketSecondsFromAuditCreditId(`video-audit:${bucketSec}s`)).toBe(bucketSec)
195
+ expect(bucketSecondsFromAuditCreditId(`video-audit:auto:${bucketSec}s`)).toBe(bucketSec)
196
+ // Round-trips the builder's own output too.
197
+ expect(bucketSecondsFromAuditCreditId(buildVideoAuditCreditId({ analysisProvided: true, durationSec: bucketSec }))).toBe(bucketSec)
198
+ expect(bucketSecondsFromAuditCreditId(buildVideoAuditCreditId({ analysisProvided: false, durationSec: bucketSec }))).toBe(bucketSec)
199
+ }
200
+ expect(bucketSecondsFromAuditCreditId("video-audit")).toBeNull() // bare base-family id, no bucket
201
+ expect(bucketSecondsFromAuditCreditId("video-audit:auto")).toBeNull() // bare auto-family id, no bucket
202
+ // The VA-anchored id space must never be mistaken for an audit id, and vice versa.
203
+ expect(bucketSecondsFromAuditCreditId("video-analysis:gemini-3-flash:60s")).toBeNull()
204
+ expect(bucketSecondsFromAuditCreditId("video-analysis:mixed:600s")).toBeNull()
205
+ expect(bucketSecondsFromCreditId("video-audit:60s")).toBeNull()
206
+ expect(bucketSecondsFromCreditId("video-audit:auto:60s")).toBeNull()
207
+ expect(bucketSecondsFromAuditCreditId("garbage")).toBeNull()
208
+ expect(bucketSecondsFromAuditCreditId("")).toBeNull()
209
+ expect(bucketSecondsFromAuditCreditId("video-audit:60seconds")).toBeNull()
210
+ expect(bucketSecondsFromAuditCreditId("video-audit-auto:60s")).toBeNull() // hyphen, not colon — not a legal id
211
+ })
212
+
213
+ it("VIDEO_AUDIT_BUCKET_CREDITS: exactly both families × 4 buckets, positive integers, bare-id ceiling pins", () => {
214
+ for (const bucketSec of VIDEO_ANALYSIS_DURATION_BUCKETS) {
215
+ for (const id of [`video-audit:${bucketSec}s`, `video-audit:auto:${bucketSec}s`]) {
216
+ const credits = VIDEO_AUDIT_BUCKET_CREDITS[id]
217
+ expect(credits, `missing entry for ${id}`).toBeDefined()
218
+ expect(Number.isInteger(credits)).toBe(true)
219
+ expect(credits).toBeGreaterThan(0)
220
+ }
221
+ }
222
+ // Exactly 2 families × 4 buckets — no stray keys, no bare-id keys (bare
223
+ // ids live only in model-catalog.ts's pricing rows, derived from the
224
+ // 600s bucket here, same convention as VIDEO_ANALYSIS_BUCKET_CREDITS).
225
+ expect(Object.keys(VIDEO_AUDIT_BUCKET_CREDITS)).toHaveLength(8)
226
+ // Bare-id values quoted in the task/catalog must equal each family's 600s ceiling.
227
+ expect(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:600s"]).toBe(1066)
228
+ expect(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:auto:600s"]).toBe(1912)
229
+ })
230
+
231
+ it("auto family = base family + the gemini-3-flash (legacy fast tier) row at the same bucket, exactly — single-source, never hand-added", () => {
232
+ // Single-source assertion: reads the fast-tier row straight out of
233
+ // VIDEO_ANALYSIS_BUCKET_CREDITS rather than re-hardcoding 180/185/514/846
234
+ // here, so this test can't silently drift from that table either.
235
+ for (const bucketSec of VIDEO_ANALYSIS_DURATION_BUCKETS) {
236
+ const base = VIDEO_AUDIT_BUCKET_CREDITS[`video-audit:${bucketSec}s`]!
237
+ const auto = VIDEO_AUDIT_BUCKET_CREDITS[`video-audit:auto:${bucketSec}s`]!
238
+ const fastRow = VIDEO_ANALYSIS_BUCKET_CREDITS[`video-analysis:gemini-3-flash:${bucketSec}s`]!
239
+ expect(auto - base, `auto-base mismatch at ${bucketSec}s`).toBe(fastRow)
240
+ }
241
+ })
242
+ })
@@ -353,6 +353,19 @@ describe("misc", () => {
353
353
  expect(isOversizedScene(0, 8)).toBe(false)
354
354
  expect(isOversizedScene(0, 8.5)).toBe(true)
355
355
  })
356
+
357
+ it("does not flag an exactly-8s scene whose float subtraction overshoots", () => {
358
+ // Real job bdc9c8eb: 12.67 → 20.67 computes as 8.000000000000002.
359
+ expect(20.67 - 12.67).toBeGreaterThan(8) // the hazard this guards
360
+ expect(isOversizedScene(12.67, 20.67)).toBe(false)
361
+ // Same hazard at other offsets — none of these are genuinely over the cap.
362
+ expect(isOversizedScene(4.67, 12.67)).toBe(false)
363
+ expect(isOversizedScene(0.1, 8.1)).toBe(false)
364
+ })
365
+
366
+ it("still flags a real overshoot far smaller than a boundary step", () => {
367
+ expect(isOversizedScene(0, 8.01)).toBe(true)
368
+ })
356
369
  it("aspectRatioFromDims snaps to nearest standard, else reduces", () => {
357
370
  expect(aspectRatioFromDims(1920, 1080)).toBe("16:9")
358
371
  expect(aspectRatioFromDims(1080, 1920)).toBe("9:16")
@@ -15,9 +15,13 @@ import {
15
15
  RESOLUTION_DURATION_PRICING,
16
16
  VEO_RESOLUTION_TIERED_PROVIDERS,
17
17
  VIDEO_DURATION_TIERS,
18
+ PRICING_DEFAULT_DURATION_SEC,
19
+ PRICING_DEFAULT_RESOLUTION,
18
20
  MOTION_DURATION_TIERS,
19
21
  T2I_TO_I2I_VARIANT,
20
22
  isVeoProvider,
23
+ isMinimaxH3Provider,
24
+ normalizeMinimaxH3Resolution,
21
25
  getVideoAudioCapability,
22
26
  } from "./model-constants.js"
23
27
  import { isFlux2Model } from "./flux2-pricing.js"
@@ -219,8 +223,12 @@ export function buildVideoCreditModelIdentifier(
219
223
  return effectiveProvider
220
224
  }
221
225
 
222
- const parsed = typeof duration === "string" ? parseInt(duration, 10) : (duration ?? 5)
223
- const durationSec = Number.isNaN(parsed) ? 5 : parsed
226
+ // A named-provider request with NO duration renders the model's own default
227
+ // (kie/models.ts extraParams), so price that default — not the global 5s —
228
+ // for providers whose per-second tiers wouldn't snap 5 up to it (minimax-h3).
229
+ const durationFallback = PRICING_DEFAULT_DURATION_SEC[effectiveProvider] ?? 5
230
+ const parsed = typeof duration === "string" ? parseInt(duration, 10) : (duration ?? durationFallback)
231
+ const durationSec = Number.isNaN(parsed) ? durationFallback : parsed
224
232
  const tiers = VIDEO_DURATION_TIERS[effectiveProvider]
225
233
  if (!tiers) return effectiveProvider
226
234
 
@@ -253,7 +261,13 @@ export function buildVideoCreditModelIdentifier(
253
261
  // maps to its top priced tier instead of emitting an unpriced composite
254
262
  // (which the hard-fail credit guard would 503 on at runtime).
255
263
  const supported = MODEL_CATALOG[effectiveProvider]?.resolutions ?? ["480p", "720p", "1080p"]
256
- const want = resolution === "4k" ? "4k" : resolution === "1080p" ? "1080p" : resolution === "720p" ? "720p" : "480p"
264
+ // An OMITTED resolution renders the model's KIE-side default, so price that
265
+ // default rather than the cheapest tier — otherwise an intent-less request
266
+ // reserves 480p against a 720p render and commit_credits (refund-only) can
267
+ // never collect the delta. Providers without an entry keep the historical
268
+ // 480p fallback, so this cannot reprice anything already live.
269
+ const requested = resolution ?? PRICING_DEFAULT_RESOLUTION[effectiveProvider]
270
+ const want = requested === "4k" ? "4k" : requested === "1080p" ? "1080p" : requested === "720p" ? "720p" : "480p"
257
271
  // Unsupported (e.g. a stale 1080p on seedance-2-mini) clamps to the model's
258
272
  // top priced tier so the emitted composite is always seeded.
259
273
  const res = supported.includes(want) ? want : (supported[supported.length - 1] ?? "480p")
@@ -271,6 +285,16 @@ export function buildVideoCreditModelIdentifier(
271
285
  identifier += `:${res}`
272
286
  }
273
287
 
288
+ // MiniMax Hailuo 3 (768P lever, 2026-08-03): bare duration composites stay
289
+ // the 2K (default) rate — byte-identical to the pre-lever seeded rows, so
290
+ // existing workflows and admin overrides keep their ids — and only a
291
+ // verified 768P selection appends ":768p". normalizeMinimaxH3Resolution
292
+ // collapses anything else to 2K, matching what KIE renders for an
293
+ // omitted/unknown value, so billing can never undercut the render.
294
+ if (isMinimaxH3Provider(effectiveProvider) && normalizeMinimaxH3Resolution(resolution) === "768P") {
295
+ identifier += ":768p"
296
+ }
297
+
274
298
  return identifier
275
299
  }
276
300
 
@@ -56,6 +56,7 @@ export const VIDEO_CLIP_CREDITS: Record<string, VideoClipCost> = {
56
56
  "seedance-2": { credits: 50, clipSeconds: 8 }, // seedance-2:8s:720p-ref
57
57
  "seedance-2-fast": { credits: 40, clipSeconds: 8 }, // seedance-2-fast:8s:720p-ref
58
58
  "seedance-2-mini": { credits: 25, clipSeconds: 8 }, // seedance-2-mini:8s:720p-ref
59
+ "seedance-2-5": { credits: 76, clipSeconds: 8 }, // seedance-2-5:8s:720p-ref
59
60
  "veo3": { credits: 79, clipSeconds: 8 }, // flat per generation (VEO 3.1 Quality)
60
61
  "veo3.1": { credits: 19, clipSeconds: 6 }, // veo3.1 @ 720p
61
62
  "veo3_lite": { credits: 10, clipSeconds: 6 }, // veo3_lite @ 720p
package/src/index.ts CHANGED
@@ -97,10 +97,12 @@ export {
97
97
  GUIDANCE_SCALE_SUPPORT,
98
98
  SEEDANCE_2_PROVIDERS,
99
99
  SEEDANCE_2_REF_LIMITS,
100
+ SEEDANCE_2_5_REF_LIMITS,
100
101
  SEEDANCE_2_EXTEND_STITCH,
101
102
  SEEDANCE_2_R2V_MIN_REF_VIDEO_SEC,
102
103
  SEEDANCE_2_CONTINUATION_REF_SEC,
103
104
  NATIVE_ADAPTIVE_ASPECT,
105
+ FRAME_MODE_ADAPTIVE_ONLY_ASPECT,
104
106
  VIDEO_REF_LIMITS_BY_PROVIDER,
105
107
  VIDEO_PROVIDERS_REQUIRING_IMAGE,
106
108
  videoProviderRequiresImage,
@@ -108,8 +110,25 @@ export {
108
110
  VIDEO_GEN_COLLAPSED_T2V_IDS,
109
111
  resolveVideoProviderForMode,
110
112
  isSeedance2Provider,
113
+ MINIMAX_H3_PROVIDERS,
114
+ isMinimaxH3Provider,
115
+ MINIMAX_H3_DEFAULT_RESOLUTION,
116
+ normalizeMinimaxH3Resolution,
117
+ VIDEO_PROVIDERS_WITHOUT_DISPATCH,
118
+ GVP_DEFAULT_PROVIDER,
111
119
  GVP_SUPPORTED_PROVIDERS,
112
120
  isGvpSupportedProvider,
121
+ GVP_EXTEND_PROVIDERS,
122
+ supportsExtendRender,
123
+ GVP_END_FRAME_PROVIDERS,
124
+ supportsEndAnchor,
125
+ GVP_ANCHOR_CHOICES,
126
+ resolveGvpAnchorWire,
127
+ segmentDurationsFor,
128
+ minSegmentSecFor,
129
+ maxSegmentSecFor,
130
+ hasContiguousSegmentDurations,
131
+ maxSegmentsFor,
113
132
  defaultVideoAspectRatio,
114
133
  CHARACTER_MOTION_PROVIDERS,
115
134
  LOCATION_ATMOSPHERE_PROVIDERS,
@@ -126,6 +145,8 @@ export {
126
145
  DEFAULT_VIDEO_PROVIDER,
127
146
  DEFAULT_VIDEO_DURATION_SEC,
128
147
  applyDefaultVideoSelection,
148
+ PRICING_DEFAULT_DURATION_SEC,
149
+ PRICING_DEFAULT_RESOLUTION,
129
150
  } from "./model-constants.js"
130
151
 
131
152
  export {
@@ -177,6 +198,8 @@ export type {
177
198
  VideoModelCapabilities,
178
199
  VideoAudioMode,
179
200
  VideoAudioCapability,
201
+ GvpAnchorChoice,
202
+ GvpAnchorWireMode,
180
203
  } from "./model-constants.js"
181
204
 
182
205
 
@@ -895,6 +918,8 @@ export * from "./reference-roles.js"
895
918
  export * from "./video-analysis.js"
896
919
 
897
920
  // --- Video-analysis pricing (duration buckets + structural credit formula) ---
921
+ // Also hosts video-audit's sibling pricing (VIDEO_AUDIT_BUCKET_CREDITS +
922
+ // buildVideoAuditCreditId / videoAuditCreditsForBucket / bucketSecondsFromAuditCreditId).
898
923
  export * from "./video-analysis-pricing.js"
899
924
 
900
925
  // --- Smart-cut best-pair search windows (shared bound + clamp) ---
package/src/llm-models.ts CHANGED
@@ -712,14 +712,17 @@ export const VIDEO_ANALYSIS_MIXED_TIERS = ["mixed", "mixed-fast", "smart"] as co
712
712
  export type VideoAnalysisMixedTier = (typeof VIDEO_ANALYSIS_MIXED_TIERS)[number]
713
713
  /**
714
714
  * `smart` is an ENGINE-PLAN sentinel like the mixed tiers — it names a plan, not a
715
- * model — but it is the opposite kind of plan, and the distinction is the whole
715
+ * model — but it is a different SHAPE of plan, and the distinction is the whole
716
716
  * reason it exists as a separate tier rather than a repricing of the others.
717
717
  *
718
718
  * The economy tiers (`fast`, `pro`, `mixed`, `mixed-fast`) run several passes on
719
- * the cheaper proxied transport and vote. That transport is 3-4x cheaper per token
720
- * and additionally does no deep reasoning, which is why they cost single-digit
721
- * credits — and also why they are less accurate. `smart` runs ONE pass on the
722
- * native transport with everything turned up, which is where the accuracy is.
719
+ * the cheaper proxied transport and vote. That transport is cheaper per token and
720
+ * additionally does no deep reasoning, which is why they cost less — and also why
721
+ * they are less accurate. `smart` is a HYBRID plan (2026-08-03 re-plan): one
722
+ * native-transport skeleton pass with everything turned up, blended with several
723
+ * economy-transport donor rolls, and the merged result is always refined —
724
+ * `selectionMode` (the `choose`/`combine` toggle the other tiers expose) does not
725
+ * apply to `smart`; it always applies the equivalent of `combine`.
723
726
  *
724
727
  * As with the mixed sentinels, what the plan does internally is deliberately not
725
728
  * published here; only the wire vocabulary is contract.