@nodaro/shared 3.9.0 → 3.10.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/dist/index.cjs +254 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +741 -30
- package/dist/index.d.ts +741 -30
- package/dist/index.js +231 -51
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/credit-identifiers.test.ts +51 -1
- package/src/__tests__/gvp-supported-providers.test.ts +20 -3
- package/src/__tests__/model-catalog-sections.test.ts +59 -0
- package/src/__tests__/model-tree.test.ts +2 -2
- package/src/__tests__/parameter-node-value.test.ts +35 -0
- package/src/__tests__/pricing-default-duration.test.ts +45 -0
- package/src/__tests__/prompt-length-limits.test.ts +4 -0
- package/src/__tests__/scene3d-delivery-review.test.ts +512 -0
- package/src/__tests__/template-categories.test.ts +45 -0
- package/src/__tests__/video-ref-limits.test.ts +11 -2
- package/src/credit-identifiers.ts +44 -9
- package/src/i18n/character-motion.ar.ts +1031 -0
- package/src/i18n/character-motion.de.ts +1031 -0
- package/src/i18n/character-motion.es.ts +1031 -0
- package/src/i18n/character-motion.fr.ts +1031 -0
- package/src/i18n/character-motion.he.ts +1031 -0
- package/src/i18n/character-motion.hi.ts +1031 -0
- package/src/i18n/character-motion.ja.ts +1031 -0
- package/src/i18n/character-motion.ko.ts +1031 -0
- package/src/i18n/character-motion.pt-BR.ts +1031 -0
- package/src/i18n/character-motion.ru.ts +1031 -0
- package/src/i18n/character-motion.zh-CN.ts +1031 -0
- package/src/i18n/types.ts +1 -0
- package/src/index.ts +21 -0
- package/src/model-catalog.ts +49 -32
- package/src/model-constants.ts +50 -11
- package/src/node-execution-state.ts +95 -0
- package/src/parameter-node-value.ts +33 -0
- package/src/presentation-utils.ts +1 -0
- package/src/pro-3d-render.ts +159 -0
- package/src/scene3d-delivery-notes.ts +490 -0
- package/src/scene3d-v2-plan.ts +8 -2
- package/src/smart-cut-windows.ts +15 -10
- package/src/template-categories.ts +67 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
SCENE3D_ASSERTION_RESTORED_CODE,
|
|
4
|
+
SCENE3D_REMEDY_AUTO_APPLIED_CODE,
|
|
5
|
+
SCENE3D_REVIEW_REFUSED_CODE,
|
|
6
|
+
SCENE3D_REVIEW_UNAVAILABLE_CODE,
|
|
7
|
+
SCENE3D_REVIEW_UNAVAILABLE_REASONS,
|
|
8
|
+
isScene3DReviewUnavailableReason,
|
|
9
|
+
scene3DReviewNote,
|
|
10
|
+
scene3DReviewVerdictOf,
|
|
11
|
+
type Scene3DAuthoringDelivery,
|
|
12
|
+
type Scene3DAuthoringValidation,
|
|
13
|
+
type Scene3DReviewUnavailable,
|
|
14
|
+
type Scene3DReviewUnavailableReason,
|
|
15
|
+
type Scene3DReviewVerdict,
|
|
16
|
+
} from "../scene3d-delivery-notes.js"
|
|
17
|
+
import { isPro3DRenderJobOutput, pro3DRenderJobOutputSchema, pro3DRenderReviewVerdictSchema } from "../pro-3d-render.js"
|
|
18
|
+
import { FIXTURE_FPS, FIXTURE_FRAMES, FIXTURE_HEIGHT, FIXTURE_WIDTH, planV2 } from "./scene3d-v2-fixtures.js"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A delivery the visual reviewer did not APPROVE is the one Scene3D outcome a caller CANNOT
|
|
22
|
+
* infer from the fields it already reads: the job completed, `videoUrl` is real, and
|
|
23
|
+
* `validation.status` is `"passed"`. Only `metadata.review` says the reviewer refused the scene
|
|
24
|
+
* anyway — or that it never answered at all. These tests pin the ways that fact gets read wrong:
|
|
25
|
+
* off the status, off the warning count, and off a `verdict` check that knows only `"refused"`.
|
|
26
|
+
*/
|
|
27
|
+
describe("scene3DReviewVerdictOf", () => {
|
|
28
|
+
const verdict: Scene3DReviewVerdict = {
|
|
29
|
+
verdict: "refused",
|
|
30
|
+
objections: [
|
|
31
|
+
{ category: "motion", what: "The suitcase does not cross the frame.", correction: "Extend the keyframe span.", frames: [0, 4] },
|
|
32
|
+
],
|
|
33
|
+
observed: "The frames establish a central square pillar.",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it("reads the verdict off a delivered result whose validation still says passed", () => {
|
|
37
|
+
const output = {
|
|
38
|
+
videoUrl: "https://example.invalid/scene.mp4",
|
|
39
|
+
validation: { status: "passed", warnings: [{ code: SCENE3D_REVIEW_REFUSED_CODE, message: "motion: …" }] },
|
|
40
|
+
metadata: { width: 1920, height: 1080, review: verdict },
|
|
41
|
+
}
|
|
42
|
+
expect(scene3DReviewVerdictOf(output)).toEqual(verdict)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("is undefined for a clean delivery, so absence means nothing to report", () => {
|
|
46
|
+
expect(scene3DReviewVerdictOf({ metadata: { width: 1920 }, validation: { status: "passed", warnings: [] } })).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/** A refusal that named nothing actionable is the shape that makes a critic useless; losing
|
|
50
|
+
* it would make it indistinguishable from a clean run. */
|
|
51
|
+
it("keeps a refusal that raised no objection at all", () => {
|
|
52
|
+
const empty = scene3DReviewVerdictOf({ metadata: { review: { verdict: "refused", objections: [] } } })
|
|
53
|
+
expect(empty).toEqual({ verdict: "refused", objections: [] })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("never invents a verdict from a warning, a status, or a malformed review", () => {
|
|
57
|
+
expect(scene3DReviewVerdictOf({ validation: { warnings: [{ code: SCENE3D_REVIEW_REFUSED_CODE, message: "x" }] } })).toBeUndefined()
|
|
58
|
+
expect(scene3DReviewVerdictOf({ validation: { status: "failed" } })).toBeUndefined()
|
|
59
|
+
expect(scene3DReviewVerdictOf({ metadata: { review: { verdict: "accepted", objections: [] } } })).toBeUndefined()
|
|
60
|
+
expect(scene3DReviewVerdictOf({ metadata: { review: "refused" } })).toBeUndefined()
|
|
61
|
+
expect(scene3DReviewVerdictOf(null)).toBeUndefined()
|
|
62
|
+
expect(scene3DReviewVerdictOf(undefined)).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** Objections arrive from a model's report through JSON; a half-formed one must degrade to a
|
|
66
|
+
* readable entry rather than take the whole delivered scene down with it. */
|
|
67
|
+
it("drops an objection with no text and defaults the rest rather than refusing the verdict", () => {
|
|
68
|
+
const read = scene3DReviewVerdictOf({
|
|
69
|
+
metadata: { review: { verdict: "refused", objections: [
|
|
70
|
+
{ category: "", what: "Lighting is flat.", frames: [2, -1, "x", 7] },
|
|
71
|
+
{ category: "motion" },
|
|
72
|
+
"not an objection",
|
|
73
|
+
], observed: "" } },
|
|
74
|
+
})
|
|
75
|
+
expect(read).toEqual({
|
|
76
|
+
verdict: "refused",
|
|
77
|
+
objections: [{ category: "unsupported", what: "Lighting is flat.", frames: [2, 7] }],
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The sibling verdict, and the reason the reader could not stay keyed on `"refused"`.
|
|
83
|
+
*
|
|
84
|
+
* A review whose provider never answered delivers an assertion-passing scene with NO opinion
|
|
85
|
+
* on it. Read through a `verdict === "refused"` test that returns `undefined` for everything
|
|
86
|
+
* else, such a delivery is indistinguishable from a clean one — the exact silence this module
|
|
87
|
+
* exists to prevent.
|
|
88
|
+
*/
|
|
89
|
+
it("reads the verdict of a scene NOBODY reviewed, which a refused-only reader would drop", () => {
|
|
90
|
+
const unreviewed = {
|
|
91
|
+
metadata: { review: { verdict: "unavailable", reason: "provider", attempts: 2, objections: [] } },
|
|
92
|
+
validation: {
|
|
93
|
+
status: "passed",
|
|
94
|
+
warnings: [{ code: SCENE3D_REVIEW_UNAVAILABLE_CODE, message: "… delivered unreviewed." }],
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
expect(scene3DReviewVerdictOf(unreviewed)).toEqual({
|
|
98
|
+
verdict: "unavailable", reason: "provider", attempts: 2, objections: [],
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/** A review is BATCHED: what answered before the outage is real evidence, and is not the
|
|
103
|
+
* whole verdict. Both facts have to survive the read, or `objections` reads as approval. */
|
|
104
|
+
it("keeps the batches that answered before the provider went away, on the unavailable arm", () => {
|
|
105
|
+
const read = scene3DReviewVerdictOf({
|
|
106
|
+
metadata: { review: { verdict: "unavailable", reason: "provider", attempts: 2, objections: [
|
|
107
|
+
{ category: "motion", what: "The suitcase never crosses.", frames: [0] },
|
|
108
|
+
], observed: "A red suitcase on a pale floor." } },
|
|
109
|
+
})
|
|
110
|
+
expect(read).toEqual({
|
|
111
|
+
verdict: "unavailable", reason: "provider", attempts: 2,
|
|
112
|
+
objections: [{ category: "motion", what: "The suitcase never crosses.", frames: [0] }],
|
|
113
|
+
observed: "A red suitcase on a pale floor.",
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
/** `attempts` decides a sentence, never a control flow, so a nonsense one degrades to the
|
|
118
|
+
* floor the fact itself guarantees: a verdict exists, so the review was asked at least once. */
|
|
119
|
+
it("clamps a nonsense attempt count rather than dropping an otherwise good verdict", () => {
|
|
120
|
+
for (const attempts of [0, -3, 1.5, "two", undefined, Number.NaN]) {
|
|
121
|
+
expect(scene3DReviewVerdictOf({ metadata: { review: { verdict: "unavailable", attempts, objections: [] } } }))
|
|
122
|
+
.toEqual({ verdict: "unavailable", reason: "provider", attempts: 1, objections: [] })
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("still refuses a verdict it has never heard of, on either arm", () => {
|
|
127
|
+
expect(scene3DReviewVerdictOf({ metadata: { review: { verdict: "unreachable", objections: [] } } })).toBeUndefined()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Round 10ag: a review the provider ANSWERED, unusably on every asking, is the same unreviewed
|
|
132
|
+
* delivery with `reason: "unusable"`. The reader used to hard-code `"provider"`, so every SDK
|
|
133
|
+
* consumer that went through it lost the one field that makes "did not reach its provider"
|
|
134
|
+
* untrue — and then printed that sentence.
|
|
135
|
+
*/
|
|
136
|
+
it("keeps the unusable reason, and reads a reason it does not know as provider", () => {
|
|
137
|
+
expect(scene3DReviewVerdictOf({
|
|
138
|
+
metadata: { review: { verdict: "unavailable", reason: "unusable", attempts: 2, objections: [] } },
|
|
139
|
+
})).toEqual({ verdict: "unavailable", reason: "unusable", attempts: 2, objections: [] })
|
|
140
|
+
for (const reason of ["weather", "", 7, null, undefined, { cause: "unusable" }]) {
|
|
141
|
+
expect(scene3DReviewVerdictOf({ metadata: { review: { verdict: "unavailable", reason, attempts: 2, objections: [] } } }))
|
|
142
|
+
.toEqual({ verdict: "unavailable", reason: "provider", attempts: 2, objections: [] })
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The note is the anti-invention guard: a surface that spells its own sentence writes "the
|
|
149
|
+
* reviewer refused this scene" for the arm it was not thinking about, and a scene NOBODY
|
|
150
|
+
* reviewed is then reported as carrying an opinion that does not exist.
|
|
151
|
+
*/
|
|
152
|
+
describe("scene3DReviewNote", () => {
|
|
153
|
+
it("never says refused about a scene nobody reviewed, and counts the asks", () => {
|
|
154
|
+
const note = scene3DReviewNote({ verdict: "unavailable", reason: "provider", attempts: 2, objections: [] })
|
|
155
|
+
expect(note).toContain("did not reach its provider in 2 attempts")
|
|
156
|
+
expect(note).toContain("delivered unreviewed")
|
|
157
|
+
expect(note).not.toMatch(/refus/i)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it("says one attempt in the singular, and names the partial findings as partial", () => {
|
|
161
|
+
expect(scene3DReviewNote({ verdict: "unavailable", reason: "provider", attempts: 1, objections: [] }))
|
|
162
|
+
.toContain("in one attempt")
|
|
163
|
+
const partial = scene3DReviewNote({
|
|
164
|
+
verdict: "unavailable", reason: "provider", attempts: 2,
|
|
165
|
+
objections: [{ category: "motion", what: "No crossing.", frames: [] }],
|
|
166
|
+
})
|
|
167
|
+
expect(partial).toContain("not the whole verdict")
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Pinned WORD FOR WORD, both arms, against the sentence the engine's own leading
|
|
172
|
+
* `SCENE_REVIEW_UNAVAILABLE` warning carries (`sceneReviewUnavailableSentence`, delivered lane).
|
|
173
|
+
* A banner that paraphrases the warning printed right under it describes one run two ways.
|
|
174
|
+
*/
|
|
175
|
+
it("says the review returned no usable verdict when the provider answered, in the engine's words", () => {
|
|
176
|
+
expect(scene3DReviewNote({ verdict: "unavailable", reason: "unusable", attempts: 2, objections: [] }))
|
|
177
|
+
.toBe("The scene built and every mandatory assertion passed, but the visual review returned no "
|
|
178
|
+
+ "usable verdict in 2 attempts; it was delivered unreviewed.")
|
|
179
|
+
expect(scene3DReviewNote({ verdict: "unavailable", reason: "provider", attempts: 2, objections: [] }))
|
|
180
|
+
.toBe("The scene built and every mandatory assertion passed, but the visual review did not reach "
|
|
181
|
+
+ "its provider in 2 attempts; it was delivered unreviewed.")
|
|
182
|
+
const unusable = scene3DReviewNote({
|
|
183
|
+
verdict: "unavailable", reason: "unusable", attempts: 1,
|
|
184
|
+
objections: [{ category: "motion", what: "No crossing.", frames: [] }],
|
|
185
|
+
})
|
|
186
|
+
expect(unusable).toContain("returned no usable verdict in one attempt")
|
|
187
|
+
expect(unusable).toContain("not the whole verdict")
|
|
188
|
+
expect(unusable).not.toMatch(/reach its provider|refus/i)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
/** Totality over the one list: a reason added to it must get its own sentence, not inherit one. */
|
|
192
|
+
it("gives every known reason a distinct sentence", () => {
|
|
193
|
+
const notes = SCENE3D_REVIEW_UNAVAILABLE_REASONS.map((reason) =>
|
|
194
|
+
scene3DReviewNote({ verdict: "unavailable", reason, attempts: 2, objections: [] }))
|
|
195
|
+
expect(new Set(notes).size).toBe(SCENE3D_REVIEW_UNAVAILABLE_REASONS.length)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("reports a refusal as a refusal, including one that named nothing actionable", () => {
|
|
199
|
+
expect(scene3DReviewNote({ verdict: "refused", objections: [
|
|
200
|
+
{ category: "motion", what: "No crossing.", frames: [] },
|
|
201
|
+
] })).toContain("refused this scene on one finding")
|
|
202
|
+
expect(scene3DReviewNote({ verdict: "refused", objections: [] }))
|
|
203
|
+
.toContain("without naming anything to change")
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe("pro3DRenderReviewVerdictSchema", () => {
|
|
208
|
+
it("parses the verdict the plugin emits, keys and all", () => {
|
|
209
|
+
const parsed = pro3DRenderReviewVerdictSchema.safeParse({
|
|
210
|
+
verdict: "refused",
|
|
211
|
+
objections: [{ category: "evidence", what: "Could not establish the motion.", frames: [0], extra: 1 }],
|
|
212
|
+
observed: "A red suitcase.",
|
|
213
|
+
})
|
|
214
|
+
expect(parsed.success).toBe(true)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("accepts an objection that cited no frames, and refuses a verdict it does not know", () => {
|
|
218
|
+
expect(pro3DRenderReviewVerdictSchema.safeParse({
|
|
219
|
+
verdict: "refused", objections: [{ category: "style", what: "Too dark." }],
|
|
220
|
+
}).success).toBe(true)
|
|
221
|
+
expect(pro3DRenderReviewVerdictSchema.safeParse({ verdict: "accepted", objections: [] }).success).toBe(false)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("parses the unavailable verdict, and defaults the fields an older engine omits", () => {
|
|
225
|
+
const parsed = pro3DRenderReviewVerdictSchema.safeParse({
|
|
226
|
+
verdict: "unavailable", reason: "provider", attempts: 2, objections: [], extra: 1,
|
|
227
|
+
})
|
|
228
|
+
expect(parsed.success).toBe(true)
|
|
229
|
+
const bare = pro3DRenderReviewVerdictSchema.safeParse({ verdict: "unavailable", objections: [] })
|
|
230
|
+
expect(bare.success).toBe(true)
|
|
231
|
+
expect(bare.success && bare.data).toMatchObject({ reason: "provider", attempts: 1 })
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
/** Round 10ag's wire value. It used to parse — and be rewritten to `"provider"` on the way. */
|
|
235
|
+
it("keeps reason unusable rather than rewriting it to provider", () => {
|
|
236
|
+
const parsed = pro3DRenderReviewVerdictSchema.safeParse({
|
|
237
|
+
verdict: "unavailable", reason: "unusable", attempts: 2, objections: [],
|
|
238
|
+
})
|
|
239
|
+
expect(parsed.success).toBe(true)
|
|
240
|
+
expect(parsed.success && parsed.data).toMatchObject({ verdict: "unavailable", reason: "unusable", attempts: 2 })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("still falls back to provider for a reason it does not know, rather than refusing the verdict", () => {
|
|
244
|
+
for (const reason of ["weather", "", 7, null, ["unusable"]]) {
|
|
245
|
+
const parsed = pro3DRenderReviewVerdictSchema.safeParse({ verdict: "unavailable", reason, attempts: 2, objections: [] })
|
|
246
|
+
expect(parsed.success).toBe(true)
|
|
247
|
+
expect(parsed.success && parsed.data).toMatchObject({ reason: "provider", attempts: 2 })
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The guard that keeps ONE list: every reason the package declares survives the schema AND the
|
|
253
|
+
* hand reader unchanged, and the type the SDK exports is exactly that list. A reason added to the
|
|
254
|
+
* list but not to a reader — the drift this round fixed — fails here.
|
|
255
|
+
*/
|
|
256
|
+
it("round-trips every declared reason through both readers, and types the reason as that list", () => {
|
|
257
|
+
for (const reason of SCENE3D_REVIEW_UNAVAILABLE_REASONS) {
|
|
258
|
+
const review = { verdict: "unavailable", reason, attempts: 2, objections: [] }
|
|
259
|
+
const parsed = pro3DRenderReviewVerdictSchema.safeParse(review)
|
|
260
|
+
expect(parsed.success && parsed.data).toMatchObject({ reason })
|
|
261
|
+
expect(scene3DReviewVerdictOf({ metadata: { review } })).toMatchObject({ reason })
|
|
262
|
+
expect(isScene3DReviewUnavailableReason(reason)).toBe(true)
|
|
263
|
+
}
|
|
264
|
+
expect(isScene3DReviewUnavailableReason("weather")).toBe(false)
|
|
265
|
+
expectTypeOf<Scene3DReviewUnavailable["reason"]>().toEqualTypeOf<Scene3DReviewUnavailableReason>()
|
|
266
|
+
expectTypeOf<Scene3DReviewUnavailableReason>().toEqualTypeOf<"provider" | "unusable">()
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The gap this round closes, stated as the assertion that would have caught it.
|
|
272
|
+
*
|
|
273
|
+
* `pro3DRenderReviewVerdictSchema` declared `verdict: z.literal("refused")`, so the whole
|
|
274
|
+
* result of an UNREVIEWED delivery — a real, paid, playable MP4 — failed `isPro3DRenderJobOutput`
|
|
275
|
+
* over a discriminant the schema had never been taught. A reader that gates on that predicate
|
|
276
|
+
* would have shown the caller nothing at all.
|
|
277
|
+
*/
|
|
278
|
+
describe("isPro3DRenderJobOutput with a review nobody could perform", () => {
|
|
279
|
+
const completed = (review?: unknown) => ({
|
|
280
|
+
videoUrl: "https://cdn.example/scene.mp4",
|
|
281
|
+
scenePlan: planV2(),
|
|
282
|
+
sceneRevisionId: "rev-1",
|
|
283
|
+
posterAssetId: "poster-1",
|
|
284
|
+
validation: {
|
|
285
|
+
status: "passed" as const,
|
|
286
|
+
reportAssetId: "report-1",
|
|
287
|
+
warnings: [{ code: SCENE3D_REVIEW_UNAVAILABLE_CODE, message: "… delivered unreviewed." }],
|
|
288
|
+
},
|
|
289
|
+
renderer: "blender",
|
|
290
|
+
metadata: {
|
|
291
|
+
width: FIXTURE_WIDTH, height: FIXTURE_HEIGHT, fps: FIXTURE_FPS,
|
|
292
|
+
frames: FIXTURE_FRAMES, duration: FIXTURE_FRAMES / FIXTURE_FPS,
|
|
293
|
+
...(review === undefined ? {} : { review }),
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it("the fixture itself parses, so a refusal below is about the verdict and nothing else", () => {
|
|
298
|
+
expect(isPro3DRenderJobOutput(completed())).toBe(true)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it("accepts the delivered result of a scene the reviewer never reached", () => {
|
|
302
|
+
expect(isPro3DRenderJobOutput(completed({
|
|
303
|
+
verdict: "unavailable", reason: "provider", attempts: 2, objections: [],
|
|
304
|
+
}))).toBe(true)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it("accepts the delivered result of a scene whose review answered unusably, and keeps the reason", () => {
|
|
308
|
+
const parsed = pro3DRenderJobOutputSchema.safeParse(completed({
|
|
309
|
+
verdict: "unavailable", reason: "unusable", attempts: 2, objections: [],
|
|
310
|
+
}))
|
|
311
|
+
expect(parsed.success).toBe(true)
|
|
312
|
+
expect(parsed.success && parsed.data.metadata.review).toMatchObject({ verdict: "unavailable", reason: "unusable" })
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it("still accepts the refused verdict it always did, and the clean result with no review", () => {
|
|
316
|
+
expect(isPro3DRenderJobOutput(completed({ verdict: "refused", objections: [] }))).toBe(true)
|
|
317
|
+
expect(isPro3DRenderJobOutput(completed())).toBe(true)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
/** Tolerance in the direction that matters: a malformed advisory must never blank a video the
|
|
321
|
+
* platform already rendered and charged for. */
|
|
322
|
+
it("does not blank a delivered video over a malformed review", () => {
|
|
323
|
+
expect(isPro3DRenderJobOutput(completed({ verdict: "unavailable", attempts: "many", objections: [] }))).toBe(true)
|
|
324
|
+
expect(isPro3DRenderJobOutput(completed({ verdict: "unavailable", reason: "weather", objections: [] }))).toBe(true)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The regression guard for the NEXT verdict, not this one.
|
|
329
|
+
*
|
|
330
|
+
* The discriminant stays strict — guessing it is how a scene nobody reviewed gets reported as
|
|
331
|
+
* refused — but an unrecognised verdict must cost at most itself. This is the exact shape that
|
|
332
|
+
* broke here: had `review` blanked the whole result for `"unavailable"`, it will do it again
|
|
333
|
+
* for whatever the engine declares next, and the caller loses a paid MP4 over an advisory.
|
|
334
|
+
*/
|
|
335
|
+
it("keeps the delivered video when the verdict is one it has never heard of", () => {
|
|
336
|
+
const parsed = pro3DRenderJobOutputSchema.safeParse(completed({ verdict: "shrugged", objections: [] }))
|
|
337
|
+
expect(parsed.success).toBe(true)
|
|
338
|
+
expect(parsed.success && parsed.data.metadata.review).toBeUndefined()
|
|
339
|
+
// …and the run still says so through the warning, which has no code enum to refuse it.
|
|
340
|
+
expect(parsed.success && parsed.data.validation.warnings?.[0]?.code).toBe(SCENE3D_REVIEW_UNAVAILABLE_CODE)
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* `validation.sourceRetained` — the refused-authoring row's one pointer at something fetchable.
|
|
346
|
+
*
|
|
347
|
+
* A run whose recipe never compiled publishes no scene revision and no poster, so nothing on
|
|
348
|
+
* the failed row is a scene. What it can say is whether the recipe it was refused for was kept,
|
|
349
|
+
* which decides whether `GET /v1/3d-scene/deliveries/{deliveryId}` has a `source-json`
|
|
350
|
+
* descriptor to list. Declared rather than merely tolerated by the index signature, so a typed
|
|
351
|
+
* caller can read it without casting — and so it appears in the generated SDK docs at all.
|
|
352
|
+
*/
|
|
353
|
+
describe("Scene3DAuthoringValidation.sourceRetained", () => {
|
|
354
|
+
it("is a typed optional beside the fields the same block already carried", () => {
|
|
355
|
+
const refused: Scene3DAuthoringValidation = {
|
|
356
|
+
status: "failed", scope: "authored", phase: "build", passes: 3,
|
|
357
|
+
reportAssetId: "report-1", sourceRetained: true,
|
|
358
|
+
warnings: [{ code: "SCENE_RECIPE_INVALID", message: "camera.shots[0].target is not resolvable" }],
|
|
359
|
+
}
|
|
360
|
+
expectTypeOf(refused.sourceRetained).toEqualTypeOf<boolean | undefined>()
|
|
361
|
+
expect(refused.sourceRetained).toBe(true)
|
|
362
|
+
// Absent is a first-class answer everywhere in this block: every lane that DID compile
|
|
363
|
+
// carries no such flag, because there is no refused recipe to have retained.
|
|
364
|
+
const delivered: Scene3DAuthoringValidation = { status: "passed", reportAssetId: "report-2", warnings: [] }
|
|
365
|
+
expect(delivered.sourceRetained).toBeUndefined()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it("is false, not absent, on a run that never cleared admission", () => {
|
|
369
|
+
// No pass produced a recipe the compiler would admit, so there is nothing to fetch. `false`
|
|
370
|
+
// says that; absent would leave a caller unable to tell it from an older deployment.
|
|
371
|
+
const refused: Scene3DAuthoringValidation = {
|
|
372
|
+
status: "failed", scope: "authored", phase: "planning", passes: 2,
|
|
373
|
+
reportAssetId: "report-3", sourceRetained: false, warnings: [],
|
|
374
|
+
}
|
|
375
|
+
expect(refused.sourceRetained).toBe(false)
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* `mechanicalPasses` is counted APART from `repairPasses` — mechanical passes buy their own
|
|
381
|
+
* allowance (a `mechanical` quote line, released when unspent) instead of spending one of the
|
|
382
|
+
* caller's repairs. The trap this pins is that there is NO arithmetic relation between the two
|
|
383
|
+
* numbers to lean on: a run may report more mechanical passes than repairs, and the older
|
|
384
|
+
* accounting (where the count WAS a subset) is still reachable on a result quoted before the
|
|
385
|
+
* allowance existed. The discriminant for which accounting applies is the QUOTE, and the result
|
|
386
|
+
* does not carry it — so any reader deriving the relation from these two numbers is guessing.
|
|
387
|
+
*/
|
|
388
|
+
describe("Scene3DAuthoringDelivery.mechanicalPasses", () => {
|
|
389
|
+
it("is a typed optional that carries no arithmetic relation to repairPasses", () => {
|
|
390
|
+
// Two mechanical passes on a run that spent ONE repair: impossible under the old subset
|
|
391
|
+
// reading, ordinary under its own allowance. This is the case a `<=` assertion would reject.
|
|
392
|
+
const delivery: Scene3DAuthoringDelivery = {
|
|
393
|
+
repairPasses: 1,
|
|
394
|
+
mechanicalPasses: 2,
|
|
395
|
+
validation: {
|
|
396
|
+
status: "passed",
|
|
397
|
+
reportAssetId: "report-1",
|
|
398
|
+
warnings: [{
|
|
399
|
+
code: SCENE3D_REMEDY_AUTO_APPLIED_CODE,
|
|
400
|
+
message: "applied 1 remedy op for cyanVisibility: replace /camera/shots/1/lens/focalLengthMm = 30.8",
|
|
401
|
+
}],
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
expectTypeOf(delivery.mechanicalPasses).toEqualTypeOf<number | undefined>()
|
|
405
|
+
expect(delivery.mechanicalPasses).toBe(2)
|
|
406
|
+
expect(delivery.repairPasses).toBe(1)
|
|
407
|
+
expect(delivery.validation?.warnings?.[0]?.code).toBe("REMEDY_AUTO_APPLIED")
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it("is absent, not zero, on a run that took none — and absent is not evidence of none", () => {
|
|
411
|
+
// An engine that does not report the count is indistinguishable from a run that took none,
|
|
412
|
+
// which is why no reader may default it to 0 and then claim the planner authored every repair.
|
|
413
|
+
const planned: Scene3DAuthoringDelivery = { repairPasses: 1 }
|
|
414
|
+
expect(planned.mechanicalPasses).toBeUndefined()
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The answer-side restore. A repair may change what the feedback names; when an answer re-shapes a
|
|
420
|
+
* mandatory assertion it was NOT invited to touch, the engine puts that assertion back rather than
|
|
421
|
+
* refusing the answer and spending a retry to be told to restore a value it already held.
|
|
422
|
+
*/
|
|
423
|
+
describe("Scene3DAuthoringDelivery.restoredAssertions", () => {
|
|
424
|
+
it("carries the edit that put one back, beside its ASSERTION_RESTORED warning", () => {
|
|
425
|
+
const delivery: Scene3DAuthoringDelivery = {
|
|
426
|
+
restoredAssertions: [{
|
|
427
|
+
op: "replace",
|
|
428
|
+
path: "/assertions/cyanEndVisibility",
|
|
429
|
+
value: { kind: "visibility", minCoverage: 0.08 },
|
|
430
|
+
assertionId: "cyanEndVisibility",
|
|
431
|
+
reason: "the repair re-shaped a mandatory assertion the feedback does not name",
|
|
432
|
+
}],
|
|
433
|
+
validation: {
|
|
434
|
+
status: "passed",
|
|
435
|
+
reportAssetId: "report-2",
|
|
436
|
+
warnings: [{ code: SCENE3D_ASSERTION_RESTORED_CODE, message: "restored cyanEndVisibility" }],
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
expect(delivery.restoredAssertions?.[0]?.assertionId).toBe("cyanEndVisibility")
|
|
440
|
+
// `value` is deliberately `unknown` — a restored assertion holds whatever it holds, and
|
|
441
|
+
// narrowing it here would make the type wrong the first time an assertion shape changes.
|
|
442
|
+
expectTypeOf(delivery.restoredAssertions![0]!.value).toEqualTypeOf<unknown>()
|
|
443
|
+
expect(delivery.validation?.warnings?.[0]?.code).toBe("ASSERTION_RESTORED")
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The reader schema is `.passthrough()`, so the field already ARRIVED before it was declared. What
|
|
449
|
+
* declaring it buys is the refusal below: a negative or fractional count is a malformed result, and
|
|
450
|
+
* an untyped passthrough key would have carried it to a caller unexamined.
|
|
451
|
+
*/
|
|
452
|
+
describe("pro3DRenderJobOutputSchema — mechanicalPasses", () => {
|
|
453
|
+
// A REAL plan, from the shared fixture: a hand-rolled stub would fail `scenePlan` first and make
|
|
454
|
+
// every refusal below pass for the wrong reason, proving nothing about this field.
|
|
455
|
+
const base = {
|
|
456
|
+
videoUrl: "https://cdn.example/scene.mp4",
|
|
457
|
+
scenePlan: planV2(),
|
|
458
|
+
sceneRevisionId: "rev-1",
|
|
459
|
+
posterAssetId: "poster-1",
|
|
460
|
+
validation: { status: "passed" as const, reportAssetId: "report-1", warnings: [] },
|
|
461
|
+
renderer: "blender",
|
|
462
|
+
metadata: {
|
|
463
|
+
width: FIXTURE_WIDTH, height: FIXTURE_HEIGHT, fps: FIXTURE_FPS,
|
|
464
|
+
frames: FIXTURE_FRAMES, duration: FIXTURE_FRAMES / FIXTURE_FPS,
|
|
465
|
+
},
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
it("the fixture itself parses, so a refusal below is about mechanicalPasses and nothing else", () => {
|
|
469
|
+
expect(pro3DRenderJobOutputSchema.safeParse(base).success).toBe(true)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it("parses the count the engine emits, beside the repair count it is part of", () => {
|
|
473
|
+
const parsed = pro3DRenderJobOutputSchema.safeParse({ ...base, repairPasses: 2, mechanicalPasses: 1 })
|
|
474
|
+
expect(parsed.success).toBe(true)
|
|
475
|
+
if (parsed.success) expect(parsed.data.mechanicalPasses).toBe(1)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it("refuses a count that cannot describe passes that were run", () => {
|
|
479
|
+
expect(pro3DRenderJobOutputSchema.safeParse({ ...base, mechanicalPasses: -1 }).success).toBe(false)
|
|
480
|
+
expect(pro3DRenderJobOutputSchema.safeParse({ ...base, mechanicalPasses: 1.5 }).success).toBe(false)
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it("does not tie mechanicalPasses to repairPasses — more mechanical than repairs parses", () => {
|
|
484
|
+
const parsed = pro3DRenderJobOutputSchema.safeParse({ ...base, repairPasses: 1, mechanicalPasses: 2 })
|
|
485
|
+
expect(parsed.success).toBe(true)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it("parses a restored assertion, keeping an unknown value and unknown extra keys", () => {
|
|
489
|
+
const parsed = pro3DRenderJobOutputSchema.safeParse({
|
|
490
|
+
...base,
|
|
491
|
+
restoredAssertions: [{
|
|
492
|
+
op: "replace", path: "/assertions/a1", value: { minCoverage: 0.08 },
|
|
493
|
+
assertionId: "a1", reason: "not named by the feedback", frames: [0, 4],
|
|
494
|
+
}],
|
|
495
|
+
})
|
|
496
|
+
expect(parsed.success).toBe(true)
|
|
497
|
+
if (parsed.success) expect(parsed.data.restoredAssertions?.[0]?.assertionId).toBe("a1")
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it("keeps an entry whose value is absent, and refuses one that names nothing", () => {
|
|
501
|
+
// A removal carries no value; that entry is still a real restore.
|
|
502
|
+
expect(pro3DRenderJobOutputSchema.safeParse({
|
|
503
|
+
...base,
|
|
504
|
+
restoredAssertions: [{ op: "remove", path: "/assertions/a1", assertionId: "a1", reason: "r" }],
|
|
505
|
+
}).success).toBe(true)
|
|
506
|
+
// No assertionId: the entry cannot say WHAT was restored, so it is not a restore.
|
|
507
|
+
expect(pro3DRenderJobOutputSchema.safeParse({
|
|
508
|
+
...base,
|
|
509
|
+
restoredAssertions: [{ op: "replace", path: "/assertions/a1", reason: "r" }],
|
|
510
|
+
}).success).toBe(false)
|
|
511
|
+
})
|
|
512
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_TEMPLATE_CATEGORY,
|
|
4
|
+
LEGACY_TEMPLATE_CATEGORIES,
|
|
5
|
+
TEMPLATE_CATEGORIES,
|
|
6
|
+
isTemplateCategory,
|
|
7
|
+
normalizeTemplateCategory,
|
|
8
|
+
resolveTemplateCategory,
|
|
9
|
+
templateCategoryStoredValues,
|
|
10
|
+
} from "../template-categories.js"
|
|
11
|
+
|
|
12
|
+
describe("template categories", () => {
|
|
13
|
+
it("is the eight use cases, each once", () => {
|
|
14
|
+
expect(TEMPLATE_CATEGORIES).toHaveLength(8)
|
|
15
|
+
expect(new Set(TEMPLATE_CATEGORIES).size).toBe(8)
|
|
16
|
+
expect(isTemplateCategory(DEFAULT_TEMPLATE_CATEGORY)).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("every legacy value lands on a current category", () => {
|
|
20
|
+
for (const [legacy, home] of Object.entries(LEGACY_TEMPLATE_CATEGORIES)) {
|
|
21
|
+
expect(isTemplateCategory(legacy), `${legacy} is not a legacy value any more`).toBe(false)
|
|
22
|
+
expect(isTemplateCategory(home), `${legacy} → ${home}`).toBe(true)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("resolves a current value as is, a legacy one as its home, anything else as nothing", () => {
|
|
27
|
+
expect(resolveTemplateCategory("video-ads")).toBe("video-ads")
|
|
28
|
+
expect(resolveTemplateCategory("video-production")).toBe("video-ads")
|
|
29
|
+
expect(resolveTemplateCategory("nonsense")).toBeUndefined()
|
|
30
|
+
expect(resolveTemplateCategory(null)).toBeUndefined()
|
|
31
|
+
expect(resolveTemplateCategory(undefined)).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("normalizes the unknown to the default bucket", () => {
|
|
35
|
+
expect(normalizeTemplateCategory("nonsense")).toBe(DEFAULT_TEMPLATE_CATEGORY)
|
|
36
|
+
expect(normalizeTemplateCategory(null)).toBe(DEFAULT_TEMPLATE_CATEGORY)
|
|
37
|
+
expect(normalizeTemplateCategory("social-media")).toBe("social-creatives")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("lists the stored values a category matches, the category first", () => {
|
|
41
|
+
expect(templateCategoryStoredValues("video-ads")).toEqual(["video-ads", "video-production"])
|
|
42
|
+
expect(templateCategoryStoredValues("social-creatives")).toEqual(["social-creatives", "audio-music", "social-media"])
|
|
43
|
+
expect(templateCategoryStoredValues("static-ads")).toEqual(["static-ads"])
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -57,11 +57,20 @@ describe("VIDEO_REF_LIMITS_BY_PROVIDER ⇄ MODEL_CATALOG drift guard", () => {
|
|
|
57
57
|
})
|
|
58
58
|
|
|
59
59
|
describe("VIDEO_REF_LIMITS_BY_PROVIDER caps for the newly-covered models", () => {
|
|
60
|
-
it("VEO
|
|
61
|
-
expect(VIDEO_REF_LIMITS_BY_PROVIDER["veo3"]).toEqual({ images: 3 })
|
|
60
|
+
it("the ref-capable VEO SKUs carry images:3 (the REFERENCE_2_VIDEO slice cap in kie/video.ts)", () => {
|
|
62
61
|
expect(VIDEO_REF_LIMITS_BY_PROVIDER["veo3.1"]).toEqual({ images: 3 })
|
|
63
62
|
expect(VIDEO_REF_LIMITS_BY_PROVIDER["veo3_lite"]).toEqual({ images: 3 })
|
|
64
63
|
})
|
|
64
|
+
|
|
65
|
+
it("veo3 (VEO 3.1 QUALITY) carries NO cap — KIE serves reference-to-video on Fast/Lite only", () => {
|
|
66
|
+
// Its own words on a production job (2026-09-04, app-reports lane G):
|
|
67
|
+
// "Reference to video only supports the Veo Fast model and Veo Lite
|
|
68
|
+
// model." Until 2026-09-15 this entry claimed images:3, so every
|
|
69
|
+
// reference-carrying Quality run was a 422 after the credits were
|
|
70
|
+
// reserved. Absent ⇒ 0 everywhere the map is read.
|
|
71
|
+
expect(VIDEO_REF_LIMITS_BY_PROVIDER["veo3"]).toBeUndefined()
|
|
72
|
+
expect(MODEL_CATALOG["veo3"]?.features).not.toContain("reference-image")
|
|
73
|
+
})
|
|
65
74
|
it("kling-3-omni and grok-i2v carry images:7", () => {
|
|
66
75
|
expect(VIDEO_REF_LIMITS_BY_PROVIDER["kling-3-omni"]).toEqual({ images: 7 })
|
|
67
76
|
expect(VIDEO_REF_LIMITS_BY_PROVIDER["grok-i2v"]).toEqual({ images: 7 })
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
RESOLUTION_DURATION_PRICING,
|
|
16
16
|
VEO_RESOLUTION_TIERED_PROVIDERS,
|
|
17
17
|
VIDEO_DURATION_TIERS,
|
|
18
|
-
|
|
18
|
+
pricedOutputDurationSec,
|
|
19
19
|
PRICING_DEFAULT_RESOLUTION,
|
|
20
20
|
MOTION_DURATION_TIERS,
|
|
21
21
|
T2I_TO_I2I_VARIANT,
|
|
@@ -27,8 +27,44 @@ import {
|
|
|
27
27
|
normalizeWan3Resolution,
|
|
28
28
|
getVideoAudioCapability,
|
|
29
29
|
} from "./model-constants.js"
|
|
30
|
-
import { isFlux2Model } from "./flux2-pricing.js"
|
|
31
|
-
import { MODEL_CATALOG, normalizeModelInput, type ModelInputAdjustment } from "./model-catalog.js"
|
|
30
|
+
import { isFlux2Model, FLUX2_RES_MP, type Flux2Model } from "./flux2-pricing.js"
|
|
31
|
+
import { MODEL_CATALOG, normalizeModelInput, defaultResolutionFor, type ModelInputAdjustment } from "./model-catalog.js"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The megapixel tier a Flux 2 credit identifier is keyed on, for ANY incoming
|
|
35
|
+
* `resolution` value.
|
|
36
|
+
*
|
|
37
|
+
* Flux 2 is the only family whose identifier INTERPOLATES the resolution
|
|
38
|
+
* instead of matching it against a known set, and its callers are not
|
|
39
|
+
* guaranteed to hand it a Flux 2 value:
|
|
40
|
+
* - the multi-provider cost preview prices one node's data against EVERY
|
|
41
|
+
* selected provider (`frontend/src/ee/hooks/use-providers-credits-sum.ts`),
|
|
42
|
+
* so a node whose `resolution` is "2K" (the flux / nano-banana-pro value
|
|
43
|
+
* space) reaches this branch verbatim;
|
|
44
|
+
* - node data written straight into workflow JSON by an agent, an import or
|
|
45
|
+
* a template never ran the config panel's provider-change fail-safe.
|
|
46
|
+
* Interpolating that produced the off-grid id `flux-2-pro:2KMP:0ref`, which no
|
|
47
|
+
* pricing row can answer — every cost badge 503'd `price_not_configured`
|
|
48
|
+
* (18 production app-reports, 2026-09-07..14).
|
|
49
|
+
*
|
|
50
|
+
* A value off the grid snaps to the model's DEFAULT tier — the same `preferred`
|
|
51
|
+
* value `normalizeModelInput` uses — so the preview asks for exactly the id the
|
|
52
|
+
* route will reserve (both routes and the orchestrator normalize through
|
|
53
|
+
* `resolveNormalizedImageGen` first, which makes this snap a no-op for them).
|
|
54
|
+
* An ABSENT resolution keeps its long-standing meaning ("this caller has no
|
|
55
|
+
* resolution dimension at all") and stays on 1 MP rather than moving to the
|
|
56
|
+
* model default, which would silently re-price every resolution-less node.
|
|
57
|
+
*/
|
|
58
|
+
function flux2MegapixelTier(model: Flux2Model, resolution?: string): string {
|
|
59
|
+
const bare = (v: string) => v.replace(/\s*MP$/i, "").trim()
|
|
60
|
+
const raw = typeof resolution === "string" ? bare(resolution) : ""
|
|
61
|
+
if (raw === "") return "1"
|
|
62
|
+
// Numeric match so "2.0"/" 2 MP" land on the same grid point as "2", and the
|
|
63
|
+
// GRID's own spelling is what gets emitted — returning the caller's "2.0"
|
|
64
|
+
// would build ":2.0MP:", another id no pricing row carries.
|
|
65
|
+
const tier = FLUX2_RES_MP.find((t) => Number(t) === Number(raw))
|
|
66
|
+
return tier ?? bare(defaultResolutionFor(model) ?? "1 MP")
|
|
67
|
+
}
|
|
32
68
|
|
|
33
69
|
/**
|
|
34
70
|
* Compute composite model identifier for variable credit pricing.
|
|
@@ -54,8 +90,7 @@ export function buildCreditModelIdentifier(
|
|
|
54
90
|
// their cost formula charges per input MP, so the reserved identifier must
|
|
55
91
|
// reflect refs (there is no metered true-up to correct an under-reserved tier).
|
|
56
92
|
if (isFlux2Model(provider)) {
|
|
57
|
-
|
|
58
|
-
return `${provider}:${mp}MP:${Math.min(referenceImageCount ?? 0, 8)}ref`
|
|
93
|
+
return `${provider}:${flux2MegapixelTier(provider, resolution)}MP:${Math.min(referenceImageCount ?? 0, 8)}ref`
|
|
59
94
|
}
|
|
60
95
|
if (HIGH_QUALITY_PROVIDERS.has(provider) && quality === "high") {
|
|
61
96
|
return `${provider}:high`
|
|
@@ -329,10 +364,10 @@ export function buildVideoCreditModelIdentifier(
|
|
|
329
364
|
|
|
330
365
|
// A named-provider request with NO duration renders the model's own default
|
|
331
366
|
// (kie/models.ts extraParams), so price that default — not the global 5s —
|
|
332
|
-
// for providers whose per-second tiers wouldn't snap 5 up to it
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const durationSec =
|
|
367
|
+
// for providers whose per-second tiers wouldn't snap 5 up to it
|
|
368
|
+
// (minimax-h3, seedance-2-5). One helper, shared with the dynamic
|
|
369
|
+
// reference-video reservations, so the tier and the scaled reserve agree.
|
|
370
|
+
const durationSec = pricedOutputDurationSec(effectiveProvider, duration)
|
|
336
371
|
const tiers = VIDEO_DURATION_TIERS[effectiveProvider]
|
|
337
372
|
if (!tiers) return effectiveProvider
|
|
338
373
|
|