@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.
- package/dist/index.cjs +458 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +368 -8
- package/dist/index.d.ts +368 -8
- package/dist/index.js +447 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/safety-retry-policy.test.ts +62 -0
- package/src/__tests__/topaz-upscale.test.ts +132 -0
- package/src/__tests__/unresolved-ref-tokens.test.ts +66 -0
- package/src/__tests__/video-analysis.test.ts +83 -0
- package/src/__tests__/video-audio-capability.test.ts +38 -4
- package/src/__tests__/video-catalog-totality.test.ts +85 -0
- package/src/__tests__/video-collapse-parity.test.ts +76 -0
- package/src/__tests__/video-ref-video-duration-limits.test.ts +69 -0
- package/src/__tests__/video-request-normalize.test.ts +239 -0
- package/src/credit-identifiers.ts +115 -8
- package/src/index.ts +23 -3
- package/src/model-catalog.ts +288 -6
- package/src/model-constants.ts +116 -4
- package/src/node-refs.ts +82 -0
- package/src/node-runtime-keys.ts +13 -0
- package/src/safety-retry-policy.ts +37 -0
- package/src/topaz-upscale.ts +163 -0
- package/src/video-analysis.ts +70 -5
|
@@ -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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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,
|