@nodaro/shared 2.20.0 → 2.21.1

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,69 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { checkRefVideoDurations, VIDEO_REF_VIDEO_DURATION_LIMITS, VIDEO_REF_LIMITS_BY_PROVIDER } from "../index.js"
3
+
4
+ describe("checkRefVideoDurations", () => {
5
+ it("accepts seedance-2-5 clips inside [2, 30]s", () => {
6
+ expect(checkRefVideoDurations("seedance-2-5", [2, 15, 12.5])).toEqual({ ok: true })
7
+ })
8
+ it("rejects a clip under the floor and names the offender", () => {
9
+ const r = checkRefVideoDurations("seedance-2-5", [1.4])
10
+ expect(r.ok).toBe(false)
11
+ expect(r.ok === false && r.message).toContain("between 2 and 30 seconds")
12
+ })
13
+ it("rejects a clip over the ceiling", () => {
14
+ expect(checkRefVideoDurations("seedance-2-5", [31]).ok).toBe(false)
15
+ })
16
+ it("rejects a legal set whose TOTAL exceeds the cap", () => {
17
+ const r = checkRefVideoDurations("seedance-2-5", [20, 20])
18
+ expect(r.ok).toBe(false)
19
+ expect(r.ok === false && r.message).toContain("30 seconds in total")
20
+ })
21
+ it("passes any provider with no declared limit through", () => {
22
+ expect(checkRefVideoDurations("veo3", [99])).toEqual({ ok: true })
23
+ })
24
+ it("ignores unusable probe values rather than inventing a rejection", () => {
25
+ expect(checkRefVideoDurations("seedance-2-5", [Number.NaN, 0, -1])).toEqual({ ok: true })
26
+ })
27
+ it("ignores a failed probe mixed in with usable ones (NaN never inflates the total)", () => {
28
+ // The route stashes RAW per-URL probe outcomes, so a rejected ffprobe reaches
29
+ // this checker as NaN. It must neither reject on its own nor push an
30
+ // otherwise-legal set over the total cap.
31
+ expect(checkRefVideoDurations("seedance-2-5", [20, Number.NaN, 9])).toEqual({ ok: true })
32
+ })
33
+ it("only declares limits for providers that actually accept reference videos", () => {
34
+ for (const id of Object.keys(VIDEO_REF_VIDEO_DURATION_LIMITS)) {
35
+ expect((VIDEO_REF_LIMITS_BY_PROVIDER[id]?.videos ?? 0), `${id} declares a duration limit but takes no reference videos`).toBeGreaterThan(0)
36
+ }
37
+ })
38
+ })
39
+
40
+ describe("minimax-h3 reference-video bounds", () => {
41
+ // §11.3 / P4: "video duration 52838 ms, expected [2000, 15000] ms" ×2 — the
42
+ // provider's own reject text is the per-clip source; the combined cap comes
43
+ // from docs.kie.ai/market/minimax-h3/reference-to-video.
44
+ it("declares the 2-15s per-clip bound the provider enforces", () => {
45
+ expect(VIDEO_REF_VIDEO_DURATION_LIMITS["minimax-h3"]).toMatchObject({ minSec: 2, maxSec: 15 })
46
+ })
47
+ it("declares the 15s COMBINED cap the KIE doc states", () => {
48
+ expect(VIDEO_REF_VIDEO_DURATION_LIMITS["minimax-h3"]).toMatchObject({ maxTotalSec: 15 })
49
+ })
50
+ it("rejects the exact clip from the two P4 rows", () => {
51
+ const r = checkRefVideoDurations("minimax-h3", [52.838])
52
+ expect(r.ok).toBe(false)
53
+ expect(r.ok === false && r.message).toContain("between 2 and 15 seconds")
54
+ })
55
+ it("accepts a clip inside the bound", () => {
56
+ expect(checkRefVideoDurations("minimax-h3", [14.9])).toEqual({ ok: true })
57
+ })
58
+ it("accepts three clips that together stay inside the combined cap", () => {
59
+ expect(checkRefVideoDurations("minimax-h3", [5, 5, 5])).toEqual({ ok: true })
60
+ })
61
+ it("rejects three per-clip-legal videos whose TOTAL exceeds 15s", () => {
62
+ const r = checkRefVideoDurations("minimax-h3", [6, 6, 6])
63
+ expect(r.ok).toBe(false)
64
+ expect(r.ok === false && r.message).toContain("15 seconds in total")
65
+ })
66
+ it("ignores a failed probe rather than rejecting the run", () => {
67
+ expect(checkRefVideoDurations("minimax-h3", [Number.NaN, 10])).toEqual({ ok: true })
68
+ })
69
+ })
@@ -0,0 +1,239 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ normalizeVideoRequestParams,
4
+ pricedVideoSelection,
5
+ buildVideoCreditModelIdentifier,
6
+ MODEL_CATALOG,
7
+ VIDEO_GEN_PROVIDERS,
8
+ } from "../index.js"
9
+
10
+ describe("normalizeVideoRequestParams", () => {
11
+ // R6: NEAREST, not allowed[0]. A portrait request must not become landscape.
12
+ it("snaps an off-list ratio to the NEAREST member of the model's list", () => {
13
+ const r = normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "9:21" })
14
+ expect(r.aspectRatio).toBe("9:16")
15
+ expect(r.adjustments).toHaveLength(1)
16
+ })
17
+ it("snaps 4:5 and 5:4 the same way the provider adapter does", () => {
18
+ expect(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "4:5" }).aspectRatio).toBe("3:4")
19
+ expect(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "5:4" }).aspectRatio).toBe("4:3")
20
+ })
21
+
22
+ it("passes 'Auto' and 'adaptive' through untouched (the provider decides)", () => {
23
+ expect(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "Auto" }).aspectRatio).toBe("Auto")
24
+ expect(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "adaptive" }).aspectRatio).toBe("adaptive")
25
+ })
26
+
27
+ // R7: nearest band, not the cheapest. A 4k request on a 1080p-max model is
28
+ // 1080p, never 480p.
29
+ it("snaps an off-list resolution to the NEAREST declared band", () => {
30
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: "4k" }).resolution).toBe("1080p")
31
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: "2k" }).resolution).toBe("1080p")
32
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: "360p" }).resolution).toBe("480p")
33
+ })
34
+
35
+ // §11.3 log-pull: two t2v rows failed createTask with "resolution is not
36
+ // within the range of allowed options". KIE exposes only 480p/720p/1080p for
37
+ // seedance-2-5 (providers/kie/models.ts:664-680, live probes 2026-08-08 and
38
+ // 08-17) while the route types `resolution` as a bare string. Asserts the
39
+ // exact snapped value, not membership — R7: the nearest band is 1080p, and a
40
+ // `toContain` pin would have hidden a silent downgrade to 480p.
41
+ it("snaps the off-list seedance-2-5 resolutions the t2v route admits, to the NEAREST band", () => {
42
+ for (const bad of ["2k", "4k", "1440p"]) {
43
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: bad }).resolution).toBe("1080p")
44
+ }
45
+ })
46
+
47
+ // R3: the one place a case variant is canonicalised, upstream of every
48
+ // credit identifier (which key their tables case-sensitively).
49
+ it("canonicalises a case variant to the catalog's own spelling", () => {
50
+ expect(normalizeVideoRequestParams("ltx-2.3-fast", { resolution: "4K" }).resolution).toBe("4k")
51
+ expect(normalizeVideoRequestParams("ltx-2.3-fast", { resolution: "1080P" }).resolution).toBe("1080p")
52
+ })
53
+
54
+ it("snaps a resolution onto the model's own list", () => {
55
+ expect(normalizeVideoRequestParams("ltx-2.3-fast", { resolution: "720p" }).resolution).toBe("1080p")
56
+ })
57
+
58
+ it("NEVER drops a resolution for a model that declares none — dropping would lower the reserved tier", () => {
59
+ const r = normalizeVideoRequestParams("kling-turbo", { resolution: "1080p" })
60
+ expect(r.resolution).toBe("1080p")
61
+ expect(r.adjustments).toEqual([])
62
+ })
63
+
64
+ it("leaves an unknown model completely alone", () => {
65
+ const r = normalizeVideoRequestParams("not-a-model", { aspectRatio: "9:21", resolution: "720p" })
66
+ expect(r).toMatchObject({ aspectRatio: "9:21", resolution: "720p", adjustments: [] })
67
+ })
68
+
69
+ it("is idempotent", () => {
70
+ const once = normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "9:21", resolution: "1080p" })
71
+ const twice = normalizeVideoRequestParams("seedance-2-5", once)
72
+ expect(twice.aspectRatio).toBe(once.aspectRatio)
73
+ expect(twice.resolution).toBe(once.resolution)
74
+ expect(twice.adjustments).toEqual([])
75
+ })
76
+
77
+ // The normalizer runs in the creditGuard preHandler (BEFORE the route's Zod
78
+ // parse) and in every buildPayload branch (whose `data` is unvalidated
79
+ // persisted workflow JSON, and whose aspectRatio can be FieldMapping-injected
80
+ // at run time). A non-string lever must therefore coerce, never throw: a throw
81
+ // is a 500 where the route used to return a clean Zod 400, and in the DAG it
82
+ // takes the whole run down after sibling nodes have already reserved.
83
+ it("coerces a NUMERIC lever exactly like its string form, instead of throwing", () => {
84
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: 1080 as never }).resolution)
85
+ .toBe(normalizeVideoRequestParams("seedance-2-5", { resolution: "1080" }).resolution)
86
+ expect(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: 16 as never }).aspectRatio)
87
+ .toBe(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "16" }).aspectRatio)
88
+ // "1080" is not "1080p", so it snaps to the nearest band rather than matching.
89
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: 1080 as never }).resolution).toBe("1080p")
90
+ })
91
+
92
+ it("snaps a non-string lever FAIL-SAFE rather than throwing", () => {
93
+ for (const junk of [{}, [1, 2], true, () => {}]) {
94
+ const r = () => normalizeVideoRequestParams("seedance-2-5", { resolution: junk as never, aspectRatio: junk as never })
95
+ expect(r, `resolution/aspectRatio = ${String(junk)}`).not.toThrow()
96
+ const out = r()
97
+ // Unparseable ⇒ the highest declared band (never the cheapest, R7) and a
98
+ // concrete ratio — always a value the model actually accepts.
99
+ expect(MODEL_CATALOG["seedance-2-5"]!.resolutions).toContain(out.resolution)
100
+ expect(MODEL_CATALOG["seedance-2-5"]!.aspectRatios).toContain(out.aspectRatio)
101
+ }
102
+ })
103
+
104
+ it("reads null / blank as ABSENT, and never hands the raw value back", () => {
105
+ // The return type promises `string | undefined` and its callers feed it
106
+ // straight to the credit identifier and the provider wire, so a `null` or
107
+ // `""` must come back as `undefined` (the priced fill then supplies the band
108
+ // the identifier assumes) rather than as the caller's own value.
109
+ for (const blank of [null, "", " "]) {
110
+ const r = normalizeVideoRequestParams("seedance-2-5", { resolution: blank as never, aspectRatio: blank as never })
111
+ expect(r.resolution, `resolution = ${JSON.stringify(blank)}`).toBeUndefined()
112
+ expect(r.aspectRatio, `aspectRatio = ${JSON.stringify(blank)}`).toBeUndefined()
113
+ expect(r.adjustments).toEqual([])
114
+ }
115
+ })
116
+
117
+ it("never returns a non-string lever, even for an unknown model", () => {
118
+ const r = normalizeVideoRequestParams("not-a-model", { resolution: 1080 as never, aspectRatio: 16 as never })
119
+ expect(r.resolution).toBe("1080")
120
+ expect(r.aspectRatio).toBe("16")
121
+ })
122
+
123
+ it("leaves an omitted lever omitted — the pricing fill is a separate, deliberate step", () => {
124
+ const r = normalizeVideoRequestParams("ltx-2.3-pro", {})
125
+ expect(r.resolution).toBeUndefined()
126
+ expect(r.aspectRatio).toBeUndefined()
127
+ expect(r.adjustments).toEqual([])
128
+ })
129
+ })
130
+
131
+ describe("pricedVideoSelection", () => {
132
+ // (A) The identifier prices an ABSENT resolution as a concrete band. Where
133
+ // that band is the platform's DECLARED provider default, it must also be the
134
+ // value we send — reserving 1080p and sending no key at all let Replicate
135
+ // pick its own undocumented default.
136
+ it("fills the declared default band for LTX", () => {
137
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro" }).resolution).toBe("1080p")
138
+ expect(pricedVideoSelection({ provider: "ltx-2.3-fast" }).resolution).toBe("1080p")
139
+ })
140
+
141
+ it("fills the declared default band for the providers that declare one", () => {
142
+ expect(pricedVideoSelection({ provider: "seedance-2-5" }).resolution).toBe("720p")
143
+ expect(pricedVideoSelection({ provider: "wan-3" }).resolution).toBe("720p")
144
+ expect(pricedVideoSelection({ provider: "wan-3-prime" }).resolution).toBe("720p")
145
+ })
146
+
147
+ it("fills NOTHING for a provider with no declared default — its identifier fallback is a hedge, not a verified provider default", () => {
148
+ // seedance-2 / -fast / -mini pin resolution 720p KIE-side but have no
149
+ // PRICING_DEFAULT_RESOLUTION row, so the identifier prices 480p. Filling
150
+ // 480p on the wire would DOWNGRADE the render to match a known-wrong price.
151
+ expect(pricedVideoSelection({ provider: "seedance-2" }).resolution).toBeUndefined()
152
+ expect(pricedVideoSelection({ provider: "seedance-2-mini" }).resolution).toBeUndefined()
153
+ expect(pricedVideoSelection({ provider: "kling-turbo" }).resolution).toBeUndefined()
154
+ expect(pricedVideoSelection({ provider: "veo3" }).resolution).toBeUndefined()
155
+ })
156
+
157
+ it("never overrides an explicit resolution", () => {
158
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro", resolution: "4k" }).resolution).toBe("4k")
159
+ expect(pricedVideoSelection({ provider: "seedance-2-5", resolution: "480p" }).resolution).toBe("480p")
160
+ })
161
+
162
+ // (B) LTX duration: the identifier snaps onto a SEEDED per-band tier, so the
163
+ // wire must carry that tier. 7s prices as 6s — send 6s.
164
+ it("carries the seeded LTX duration tier the identifier priced", () => {
165
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro", duration: 7 }).duration).toBe(6)
166
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro", resolution: "4k", duration: 20 }).duration).toBe(10)
167
+ expect(pricedVideoSelection({ provider: "ltx-2.3-fast", duration: 20 }).duration).toBe(20)
168
+ // 20s exists only at 1080p — a 2k request snaps back onto that band's ladder.
169
+ expect(pricedVideoSelection({ provider: "ltx-2.3-fast", resolution: "2k", duration: 20 }).duration).toBe(10)
170
+ })
171
+
172
+ it("reports the LTX duration snap as an adjustment, but never the omitted-value fill", () => {
173
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro", duration: 7 }).adjustments).toHaveLength(1)
174
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro", duration: 6 }).adjustments).toEqual([])
175
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro" }).adjustments).toEqual([])
176
+ })
177
+
178
+ it("leaves duration alone for every non-LTX provider (legality is not a flat catalog list)", () => {
179
+ expect(pricedVideoSelection({ provider: "seedance-2-5", duration: 7 }).duration).toBeUndefined()
180
+ expect(pricedVideoSelection({ provider: "kling-3.0", duration: 7 }).duration).toBeUndefined()
181
+ })
182
+ })
183
+
184
+ /**
185
+ * The invariant that makes the FILL safe: carrying the priced selection to the
186
+ * wire can never move the reserved tier. (The catalog snap that runs before it
187
+ * legitimately can — an off-list "4K" on a 1080p-max model is priced at the
188
+ * band we will actually render, which is the whole point. This pins the second
189
+ * step only: given the snapped value, filling what the identifier assumed is a
190
+ * disclosure of the price already being charged, never a repricing.)
191
+ */
192
+ describe("pricedVideoSelection cannot move the credit identifier", () => {
193
+ const RESOLUTIONS = [undefined, "480p", "720p", "1080p", "2k", "4k", "4K", "720P"]
194
+ const DURATIONS = [undefined, 4, 5, 6, 7, 8, 10, 12, 20, 30]
195
+
196
+ it("covers every catalogued video provider", () => {
197
+ expect(VIDEO_GEN_PROVIDERS.length).toBeGreaterThan(20)
198
+ })
199
+
200
+ for (const nodeType of ["text-to-video", "image-to-video"] as const) {
201
+ it(`${nodeType}: id(priced) === id(raw) for every provider × resolution × duration`, () => {
202
+ const drift: string[] = []
203
+ for (const provider of VIDEO_GEN_PROVIDERS) {
204
+ for (const rawRes of RESOLUTIONS) {
205
+ for (const rawDur of DURATIONS) {
206
+ for (const hasVideoRef of [false, true]) {
207
+ const norm = normalizeVideoRequestParams(provider, { resolution: rawRes })
208
+ const priced = pricedVideoSelection({ provider, resolution: norm.resolution, duration: rawDur })
209
+ const rawId = buildVideoCreditModelIdentifier(provider, rawDur, undefined, nodeType, undefined, norm.resolution, hasVideoRef)
210
+ const pricedId = buildVideoCreditModelIdentifier(
211
+ provider,
212
+ priced.duration ?? rawDur,
213
+ undefined,
214
+ nodeType,
215
+ undefined,
216
+ norm.resolution ?? priced.resolution,
217
+ hasVideoRef,
218
+ )
219
+ if (rawId !== pricedId) {
220
+ drift.push(`${provider} res=${rawRes} dur=${rawDur} ref=${hasVideoRef}: ${rawId} → ${pricedId}`)
221
+ }
222
+ }
223
+ }
224
+ }
225
+ }
226
+ expect(drift, `the normalize+fill pair moved the reserved tier:\n${drift.join("\n")}`).toEqual([])
227
+ })
228
+ }
229
+
230
+ it("only fills a resolution the model's own catalog declares", () => {
231
+ for (const provider of VIDEO_GEN_PROVIDERS) {
232
+ const filled = pricedVideoSelection({ provider }).resolution
233
+ if (filled === undefined) continue
234
+ const declared = MODEL_CATALOG[provider]?.resolutions as readonly string[] | undefined
235
+ expect(declared, `${provider} fills "${filled}" but declares no resolutions`).toBeDefined()
236
+ expect(declared, `${provider} fills "${filled}", which is not in its catalog list`).toContain(filled)
237
+ }
238
+ })
239
+ })
@@ -235,6 +235,32 @@ const LTX_DURATION_TIERS: Record<string, Record<string, number[]>> = {
235
235
  "ltx-2.3-fast": { "1080p": [6, 8, 10, 12, 14, 16, 18, 20], "2k": [6, 8, 10], "4k": [6, 8, 10] },
236
236
  }
237
237
 
238
+ /**
239
+ * The (band, duration) tier the LTX pricing ladder charges for a request, or
240
+ * `undefined` for a non-LTX provider. Extracted so
241
+ * `buildVideoCreditModelIdentifier` and `pricedVideoSelection` read the ladder
242
+ * ONCE — the routes must send the tier they reserved, and a second copy of this
243
+ * math is exactly how the two drift apart.
244
+ *
245
+ * An unknown/absent band falls back to 1080p (LTX's own default, and the band
246
+ * `snapLtxInput` snaps to at the Replicate call), and an off-tier duration
247
+ * snaps to the NEAREST seeded one so the emitted composite always prices.
248
+ */
249
+ function ltxPricedTier(
250
+ provider: string,
251
+ resolution?: string,
252
+ duration?: number | string,
253
+ ): { band: string; duration: number } | undefined {
254
+ const bands = LTX_DURATION_TIERS[provider]
255
+ if (!bands) return undefined
256
+ const band = bands[String(resolution)] ? String(resolution) : "1080p"
257
+ const allowed = bands[band]!
258
+ const raw = typeof duration === "string" ? parseInt(duration, 10) : (duration ?? allowed[0]!)
259
+ const want = Number.isNaN(raw) ? allowed[0]! : raw
260
+ const dur = allowed.reduce((b, a) => (Math.abs(a - want) < Math.abs(b - want) ? a : b))
261
+ return { band, duration: dur }
262
+ }
263
+
238
264
  export function buildVideoCreditModelIdentifier(
239
265
  provider: string,
240
266
  duration?: number | string,
@@ -292,14 +318,9 @@ export function buildVideoCreditModelIdentifier(
292
318
  // (actual < reserved) and NEVER collects an upward delta, so an under-reserved
293
319
  // LTX run (the bare-id default = cheapest 1080p:6s tier) stays under-charged
294
320
  // even with meteredCost:true. Snap to a seeded tier so the id always prices.
295
- if (effectiveProvider === "ltx-2.3-pro" || effectiveProvider === "ltx-2.3-fast") {
296
- const bands = LTX_DURATION_TIERS[effectiveProvider]
297
- const band = bands[String(resolution)] ? String(resolution) : "1080p"
298
- const allowed = bands[band]
299
- const raw = typeof duration === "string" ? parseInt(duration, 10) : (duration ?? allowed[0])
300
- const want = Number.isNaN(raw) ? allowed[0] : raw
301
- const dur = allowed.reduce((b, a) => (Math.abs(a - want) < Math.abs(b - want) ? a : b))
302
- return `${effectiveProvider}:${band}:${dur}s`
321
+ const ltxTier = ltxPricedTier(effectiveProvider, resolution, duration)
322
+ if (ltxTier) {
323
+ return `${effectiveProvider}:${ltxTier.band}:${ltxTier.duration}s`
303
324
  }
304
325
 
305
326
  if (!DURATION_PRICED_PROVIDERS.has(effectiveProvider)) {
@@ -405,6 +426,92 @@ export function buildVideoCreditModelIdentifier(
405
426
  return identifier
406
427
  }
407
428
 
429
+ /** What the video credit identifier PRICES for a request, for the levers whose
430
+ * priced value must also be the value we SEND. */
431
+ export interface PricedVideoSelection {
432
+ /** The resolution band the reservation is priced at when the request omitted
433
+ * one — and only where the platform DECLARES that band as the provider's own
434
+ * default. `undefined` means "leave `resolution` exactly as the request had
435
+ * it": either the caller supplied one, or the provider's real default is not
436
+ * known to be the band the identifier assumes. */
437
+ resolution?: string
438
+ /** The seeded duration tier the reservation is priced at (LTX only — the one
439
+ * family whose duration ladder is per-band and case-sensitively seeded).
440
+ * `undefined` for every other provider: their duration passes through. */
441
+ duration?: number
442
+ /** Non-empty only for a lever the caller ASKED for and did not get (an LTX
443
+ * 7s snapped to the 6s tier). Filling an omitted lever is a disclosure of
444
+ * the price already being charged, not a correction, so it reports nothing. */
445
+ adjustments: ModelInputAdjustment[]
446
+ }
447
+
448
+ /**
449
+ * The other half of the video money path: `normalizeVideoRequestParams` snaps a
450
+ * value the CATALOG governs; this returns the value the PRICE ladder governs.
451
+ *
452
+ * Two defects it closes, both of the same shape — the identifier prices one
453
+ * thing and the wire carries another, and `commit_credits` (migration 176) only
454
+ * ever refunds a surplus, so the reservation is the final charge:
455
+ *
456
+ * 1. **Absent resolution.** `buildVideoCreditModelIdentifier` prices an omitted
457
+ * `resolution` as a concrete band (LTX → 1080p; a
458
+ * {@link PRICING_DEFAULT_RESOLUTION} member → its declared default). Leaving
459
+ * the key unset then lets the provider pick — documented for the KIE members
460
+ * (their `extraParams` pin the same band) but UNDOCUMENTED for LTX on
461
+ * Replicate. Sending the band we priced makes the two agree by construction.
462
+ *
463
+ * The fill is deliberately limited to providers with a DECLARED default.
464
+ * `buildVideoCreditModelIdentifier` also has un-declared fallbacks — the
465
+ * cheapest tier for a resolution-priced provider with no
466
+ * `PRICING_DEFAULT_RESOLUTION` row — and those are a HEDGE, not a verified
467
+ * provider default: seedance-2 / -fast / -mini pin `resolution: "720p"`
468
+ * KIE-side while the identifier prices 480p, so filling 480p would downgrade
469
+ * the render to match a price we already know is wrong. That mismatch is a
470
+ * pre-existing identifier bug and is fixed by seeding the row, not here.
471
+ *
472
+ * 2. **LTX duration.** The LTX ladder is seeded per (band × seconds), so the
473
+ * identifier snaps 7s onto the 6s tier. Sending 7s bills six and renders
474
+ * seven. Most other providers' durations pass through untouched: their
475
+ * legality is a flat catalog list the caller already sees, and their tiers
476
+ * are ranges (`durationSec <= maxSeconds`), not seeded points. Gemini Omni
477
+ * is the one other seeded ladder — `GEMINI_OMNI_DURATIONS` ([4, 6, 8, 10])
478
+ * is nearest-snapped by `buildVideoCreditModelIdentifier`, the same shape
479
+ * as LTX — but this function does not carry it yet: a 7s request prices
480
+ * `:6` while 7s is still what gets sent. Pre-existing, out of scope here,
481
+ * and ticketed as a follow-up (`geminiOmniPricedTier`, mirroring
482
+ * `ltxPricedTier`).
483
+ *
484
+ * Pure, and IDEMPOTENT against the identifier: feeding its output back in
485
+ * cannot move the reserved tier. `video-request-normalize.test.ts` proves that
486
+ * over every provider × resolution × duration.
487
+ */
488
+ export function pricedVideoSelection(opts: {
489
+ provider: string
490
+ resolution?: string
491
+ duration?: number | string
492
+ }): PricedVideoSelection {
493
+ const adjustments: ModelInputAdjustment[] = []
494
+ const ltx = ltxPricedTier(opts.provider, opts.resolution, opts.duration)
495
+
496
+ // The band the request will be priced AND rendered at. An explicit value
497
+ // always wins — this only ever fills an omission.
498
+ const resolution = opts.resolution
499
+ ?? (ltx ? ltx.band : PRICING_DEFAULT_RESOLUTION[opts.provider])
500
+
501
+ if (!ltx) return { resolution, adjustments }
502
+
503
+ const requested = typeof opts.duration === "string" ? parseInt(opts.duration, 10) : opts.duration
504
+ if (requested !== undefined && !Number.isNaN(requested) && requested !== ltx.duration) {
505
+ adjustments.push({
506
+ field: "duration",
507
+ from: requested,
508
+ to: ltx.duration,
509
+ reason: `LTX renders ${ltx.band} in ${ltx.duration}s steps — using ${ltx.duration}s instead of ${requested}s.`,
510
+ })
511
+ }
512
+ return { resolution, duration: ltx.duration, adjustments }
513
+ }
514
+
408
515
  /**
409
516
  * Compute composite model identifier for motion control with duration-tiered pricing.
410
517
  * Examples: "kling-3.0-motion:10s", "kling-3.0-motion:1080p:15s", "motion-transfer:5s"
package/src/index.ts CHANGED
@@ -108,6 +108,8 @@ export {
108
108
  NATIVE_ADAPTIVE_ASPECT,
109
109
  FRAME_MODE_ADAPTIVE_ONLY_ASPECT,
110
110
  VIDEO_REF_LIMITS_BY_PROVIDER,
111
+ VIDEO_REF_VIDEO_DURATION_LIMITS,
112
+ checkRefVideoDurations,
111
113
  VIDEO_PROVIDERS_REQUIRING_IMAGE,
112
114
  videoProviderRequiresImage,
113
115
  VIDEO_MODE_ALIASES,
@@ -212,6 +214,7 @@ export type {
212
214
  VideoAudioCapability,
213
215
  GvpAnchorChoice,
214
216
  GvpAnchorWireMode,
217
+ RefVideoDurationLimit,
215
218
  } from "./model-constants.js"
216
219
 
217
220
 
@@ -239,16 +242,26 @@ export {
239
242
  resolveImageGenCreditIdentifier,
240
243
  resolveNormalizedImageGen,
241
244
  buildVideoCreditModelIdentifier,
245
+ pricedVideoSelection,
242
246
  buildMotionCreditModelIdentifier,
243
247
  sunoCreditType,
244
248
  SUNO_VERSION_PRICED_OPERATIONS,
245
249
  SUNO_SELECT_OPERATIONS,
246
250
  } from "./credit-identifiers.js"
247
- export type { NormalizedImageGen } from "./credit-identifiers.js"
251
+ export type { NormalizedImageGen, PricedVideoSelection } from "./credit-identifiers.js"
248
252
 
249
253
  export * from "./credit-estimators/index.js"
250
254
  export { extractVideoDurationFromNode } from "./video-duration.js"
251
255
 
256
+ export {
257
+ resolveTopazUpscale,
258
+ TOPAZ_UPSCALE_FACTORS,
259
+ TOPAZ_DEFAULT_UPSCALE_FACTOR,
260
+ type TopazUpscaleFactor,
261
+ type TopazUpscaleAdjustment,
262
+ type TopazUpscaleResolution,
263
+ } from "./topaz-upscale.js"
264
+
252
265
 
253
266
 
254
267
 
@@ -625,8 +638,8 @@ export type { LottieOverlayCatalogEntry } from "./lottie-overlay-catalog.js"
625
638
 
626
639
  export { resolveFieldMappings, resolveLocationFields } from "./resolve-field-mappings.js"
627
640
 
628
- export { resolveNodeRefs, parseNodeRef, canonicalVarName, NODE_REF_PATTERN, RESERVED_TEMPLATE_VARS, extractReferencedLabels, combineSameLabelRefs, refHandleCategory, REF_HANDLE_CATEGORY, REFERENCE_HANDLE_MAP, referenceModalityForHandle, FRAME_TARGET_HANDLES, countRefModalityEdges } from "./node-refs.js"
629
- export type { RefCandidate, ReferenceModality, RefModalityEdge } from "./node-refs.js"
641
+ export { resolveNodeRefs, parseNodeRef, canonicalVarName, NODE_REF_PATTERN, RESERVED_TEMPLATE_VARS, extractReferencedLabels, combineSameLabelRefs, refHandleCategory, REF_HANDLE_CATEGORY, REFERENCE_HANDLE_MAP, referenceModalityForHandle, FRAME_TARGET_HANDLES, countRefModalityEdges, REF_TOKEN_NAMESPACE_PREFIXES, classifyRefToken, unresolvedRefTokens } from "./node-refs.js"
642
+ export type { RefCandidate, ReferenceModality, RefModalityEdge, RefTokenKind } from "./node-refs.js"
630
643
 
631
644
 
632
645
  export { resolveSourceThroughConnectedList } from "./list-source-resolver.js"
@@ -727,9 +740,11 @@ export {
727
740
  modelIdsByKindMode,
728
741
  buildModelMenu,
729
742
  normalizeModelInput,
743
+ normalizeVideoRequestParams,
730
744
  defaultResolutionFor,
731
745
  } from "./model-catalog.js"
732
746
  export type {
747
+ NormalizedVideoRequest,
733
748
  ModelCatalogEntry,
734
749
  ModelKind,
735
750
  ModelMode,
@@ -743,6 +758,11 @@ export type {
743
758
  NormalizedModelInput,
744
759
  } from "./model-catalog.js"
745
760
 
761
+ // Per-model safety-filter retry/fallback policy (derives from
762
+ // `ModelCatalogEntry.safetyFilter` above).
763
+ export { safetyRetryPolicy } from "./safety-retry-policy.js"
764
+ export type { SafetyRetryPolicy } from "./safety-retry-policy.js"
765
+
746
766
  export {
747
767
  STATIC_CAPTION_STYLES,
748
768
  KINETIC_CAPTION_STYLES,