@nodaro/shared 2.19.0 → 2.21.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 +548 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1043 -386
- package/dist/index.d.ts +1043 -386
- package/dist/index.js +529 -32
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/credit-identifiers.test.ts +133 -0
- package/src/__tests__/image-pricing-catalog-coverage.test.ts +139 -0
- package/src/__tests__/normalize-node-params.test.ts +36 -0
- package/src/__tests__/organizations-types.test.ts +29 -1
- package/src/__tests__/prompt-length-limits.test.ts +36 -0
- package/src/__tests__/safety-retry-policy.test.ts +47 -0
- package/src/__tests__/suno-credit-type.test.ts +54 -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-brief.test.ts +61 -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 +264 -20
- package/src/index.ts +30 -2
- package/src/model-catalog.ts +287 -6
- package/src/model-constants.ts +185 -13
- package/src/node-refs.ts +82 -0
- package/src/node-runtime-keys.ts +5 -0
- package/src/normalize-node-params.ts +8 -0
- package/src/organizations/types.ts +12 -0
- package/src/organizations/views.ts +114 -0
- package/src/safety-retry-policy.ts +37 -0
- package/src/topaz-upscale.ts +163 -0
- package/src/video-analysis.ts +115 -5
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { unresolvedRefTokens, classifyRefToken, REF_TOKEN_NAMESPACE_PREFIXES } from "../node-refs.js"
|
|
3
|
+
|
|
4
|
+
const S = (...v: string[]) => new Set(v)
|
|
5
|
+
|
|
6
|
+
describe("classifyRefToken", () => {
|
|
7
|
+
it("skips every reference namespace, not just image:", () => {
|
|
8
|
+
for (const p of REF_TOKEN_NAMESPACE_PREFIXES) {
|
|
9
|
+
expect(classifyRefToken(`${p}1`, S())).toBe("skip")
|
|
10
|
+
}
|
|
11
|
+
// Regression: {video:1} and {audio:1} classified as "missing" before this.
|
|
12
|
+
expect(classifyRefToken("video:1", S())).toBe("skip")
|
|
13
|
+
expect(classifyRefToken("audio:1", S())).toBe("skip")
|
|
14
|
+
expect(classifyRefToken("slot:hero", S())).toBe("skip")
|
|
15
|
+
// D-13c: {ref:<id>} is a live grammar (resolveRefIdTokens) — it must skip
|
|
16
|
+
// too, or the dispatch guard refuses every id-addressed reference prompt.
|
|
17
|
+
expect(classifyRefToken("ref:hero", S())).toBe("skip")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("matches the namespace case-insensitively (the resolvers' regexes are /i)", () => {
|
|
21
|
+
// D-13d: REFERENCE_TOKEN_RE is /gi and REF_ID_TOKEN_RE spells [rR][eE][fF],
|
|
22
|
+
// so {Image:1} / {Ref:x} resolve at the provider layer and must not classify
|
|
23
|
+
// as a missing node ref.
|
|
24
|
+
expect(classifyRefToken("Image:1", S())).toBe("skip")
|
|
25
|
+
expect(classifyRefToken("REF:hero", S())).toBe("skip")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("keeps reserved template vars reserved and resolves case-insensitively", () => {
|
|
29
|
+
expect(classifyRefToken("userPrompt", S())).toBe("reserved")
|
|
30
|
+
expect(classifyRefToken("MyNode", S("mynode"))).toBe("wired")
|
|
31
|
+
expect(classifyRefToken("MyNode", S())).toBe("missing")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("classifies unknown when the caller has no ref data at all", () => {
|
|
35
|
+
expect(classifyRefToken("MyNode", null)).toBe("unknown")
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe("unresolvedRefTokens", () => {
|
|
40
|
+
it("reports a token whose label matches no node at all", () => {
|
|
41
|
+
expect(unresolvedRefTokens("a {gravity flip} shot", { resolvable: S(), known: S() }))
|
|
42
|
+
.toEqual(["gravity flip"])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("passes a token whose label names a node that exists but produced nothing", () => {
|
|
46
|
+
expect(unresolvedRefTokens("say {notes}", { resolvable: S(), known: S("notes") })).toEqual([])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("passes a token that resolved", () => {
|
|
50
|
+
expect(unresolvedRefTokens("say {notes}", { resolvable: S("notes"), known: S("notes") })).toEqual([])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("passes a token with an explicit || fallback (resolveNodeRefs substitutes it)", () => {
|
|
54
|
+
expect(unresolvedRefTokens("a {mood || calm} scene", { resolvable: S(), known: S() })).toEqual([])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("never fires on the reference/recast grammars or reserved vars", () => {
|
|
58
|
+
expect(unresolvedRefTokens("{image:1:face} {video:1} {slot:x} {ref:hero} {userPrompt}", { resolvable: S(), known: S() }))
|
|
59
|
+
.toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("de-duplicates and preserves the author's casing for the message", () => {
|
|
63
|
+
expect(unresolvedRefTokens("{Describe Image} then {describe image}", { resolvable: S(), known: S() }))
|
|
64
|
+
.toEqual(["Describe Image"])
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { stripDerivedAnalysisFields, videoAnalysisResultSchema } from "../video-analysis.js"
|
|
3
|
+
|
|
4
|
+
/** A real result shape (validated below) so the strip list is tested against
|
|
5
|
+
* the canonical schema, not an invented fixture. */
|
|
6
|
+
const analysis = {
|
|
7
|
+
meta: { durationSec: 12, width: 1920, height: 1080, aspectRatio: "16:9", title: "Clip" },
|
|
8
|
+
slots: [
|
|
9
|
+
{ slotId: "hero", label: "Hero", source: "wired-character", role: "person", description: "tan man, mustache, black tee", refImageUrl: "https://r2.example/hero.png" },
|
|
10
|
+
],
|
|
11
|
+
scenes: [
|
|
12
|
+
{ sceneNumber: 1, startSec: 0, endSec: 4, label: "Hook", shotType: "Medium Close-Up", camera: "slow push-in", visual: "{slot:hero} waves", visualResolved: "Hero waves", slotRefs: ["hero"], oversized: false, audio: [] },
|
|
13
|
+
{ sceneNumber: 2, startSec: 4, endSec: 12, label: "Turn", shotType: "Medium Close-Up", camera: "static", visual: "an empty street", visualResolved: "an empty street", slotRefs: [], oversized: true, audio: [] },
|
|
14
|
+
],
|
|
15
|
+
variationFolds: [{ slotId: "hero", variationId: "v2", label: "coat" }],
|
|
16
|
+
warnings: ["auto-cast dropped one duplicate line"],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("stripDerivedAnalysisFields", () => {
|
|
20
|
+
it("the fixture is a real result (the strip list is tested against the canonical shape)", () => {
|
|
21
|
+
expect(videoAnalysisResultSchema.safeParse(analysis).success).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("drops the derived top-level and per-scene keys and keeps everything else, refImageUrl included", () => {
|
|
25
|
+
const out = stripDerivedAnalysisFields(analysis) as Record<string, unknown>
|
|
26
|
+
expect(out).not.toHaveProperty("warnings")
|
|
27
|
+
expect(out).not.toHaveProperty("variationFolds")
|
|
28
|
+
expect(out.meta).toEqual(analysis.meta)
|
|
29
|
+
expect((out.slots as Array<Record<string, unknown>>)[0].refImageUrl).toBe("https://r2.example/hero.png")
|
|
30
|
+
const scenes = out.scenes as Array<Record<string, unknown>>
|
|
31
|
+
expect(scenes).toHaveLength(2)
|
|
32
|
+
for (const scene of scenes) {
|
|
33
|
+
expect(scene).not.toHaveProperty("visualResolved")
|
|
34
|
+
expect(scene).not.toHaveProperty("slotRefs")
|
|
35
|
+
expect(scene).not.toHaveProperty("oversized")
|
|
36
|
+
expect(scene).toHaveProperty("visual")
|
|
37
|
+
expect(scene).toHaveProperty("audio")
|
|
38
|
+
expect(scene).toHaveProperty("sceneNumber")
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("never mutates its input", () => {
|
|
43
|
+
const frozen = JSON.parse(JSON.stringify(analysis))
|
|
44
|
+
stripDerivedAnalysisFields(analysis)
|
|
45
|
+
expect(analysis).toEqual(frozen)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("returns a non-object input untouched and tolerates a non-object scene entry", () => {
|
|
49
|
+
expect(stripDerivedAnalysisFields("nope")).toBe("nope")
|
|
50
|
+
expect(stripDerivedAnalysisFields(null)).toBe(null)
|
|
51
|
+
expect(stripDerivedAnalysisFields([1])).toEqual([1])
|
|
52
|
+
const out = stripDerivedAnalysisFields({ scenes: [1, "x", null] }) as { scenes: unknown[] }
|
|
53
|
+
expect(out.scenes).toEqual([1, "x", null])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("is roughly half the bytes of the full result on a real-shaped scene", () => {
|
|
57
|
+
const full = JSON.stringify(analysis).length
|
|
58
|
+
const stripped = JSON.stringify(stripDerivedAnalysisFields(analysis)).length
|
|
59
|
+
expect(stripped).toBeLessThan(full)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
VIDEO_ANALYSIS_SPEED_EFFECTS, VIDEO_ANALYSIS_SHOT_ANGLES, VIDEO_ANALYSIS_FACELESS_ANGLES,
|
|
10
10
|
VIDEO_ANALYSIS_VISUAL_EFFECTS, VIDEO_ANALYSIS_TRANSITIONS,
|
|
11
11
|
VIDEO_ANALYSIS_MAX_VARIATIONS, VIDEO_ANALYSIS_VARIATION_SLUGS, VIDEO_ANALYSIS_DEFAULT_VARIATION,
|
|
12
|
+
VIDEO_ANALYSIS_AUDIO_MODES,
|
|
12
13
|
type EntitySlot, type AudioLayer,
|
|
13
14
|
} from "../video-analysis.js"
|
|
14
15
|
|
|
@@ -218,6 +219,60 @@ describe("speech attribution (speakerSlot)", () => {
|
|
|
218
219
|
})
|
|
219
220
|
})
|
|
220
221
|
|
|
222
|
+
describe("the audio grammar (mode vocabulary + who is speaking)", () => {
|
|
223
|
+
it("distinguishes AMBIENCE from SFX — room tone is not a door slam", () => {
|
|
224
|
+
// They are different layers of a real mix and they are recreated by
|
|
225
|
+
// different means: ambience is a continuous bed, an sfx is a discrete hit.
|
|
226
|
+
// Collapsing them onto `sfx` made a scene's continuous bed indistinguishable
|
|
227
|
+
// from its one-off noises, so a recreation had to guess which it was told.
|
|
228
|
+
expect([...VIDEO_ANALYSIS_AUDIO_MODES]).toEqual(["speech", "music", "sfx", "ambience"])
|
|
229
|
+
const parsed = windowAnalysisSchema.parse({
|
|
230
|
+
slots: [slot],
|
|
231
|
+
scenes: [{ ...baseScene, audio: [{ mode: "ambience", content: "distant traffic, room tone" }] }],
|
|
232
|
+
})
|
|
233
|
+
expect(parsed.scenes[0]!.audio[0]!.mode).toBe("ambience")
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("names the speaker BOTH ways — by slot id and by cast name", () => {
|
|
237
|
+
// `speakerSlot` addresses a slot in THIS analysis; `speaker` is the plain
|
|
238
|
+
// name a document that has no slots (a studio production's cast) keys by.
|
|
239
|
+
// One layer may legitimately carry both.
|
|
240
|
+
const parsed = windowAnalysisSchema.parse({
|
|
241
|
+
slots: [slot],
|
|
242
|
+
scenes: [{ ...baseScene, audio: [{ mode: "speech", content: "As a kid…", speakerSlot: "hero", speaker: "Jack Mercer" }] }],
|
|
243
|
+
})
|
|
244
|
+
expect(parsed.scenes[0]!.audio[0]).toMatchObject({ speakerSlot: "hero", speaker: "Jack Mercer" })
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("dropUnknownSpeakers strips slot attribution from an AMBIENCE layer too — nobody is speaking", () => {
|
|
248
|
+
// The new mode must land on the non-speech side of the sanitizer, not slip
|
|
249
|
+
// through it as an unrecognised value. This one's RED was a `tsc` error on
|
|
250
|
+
// the literal, not a runtime failure — the sanitizer never validated the
|
|
251
|
+
// mode; the sibling parse case above carries the runtime pin.
|
|
252
|
+
const r = dropUnknownSpeakers([{ mode: "ambience", content: "room tone", speakerSlot: "hero" }], new Set(["hero"]))
|
|
253
|
+
expect(r.audio[0]).not.toHaveProperty("speakerSlot")
|
|
254
|
+
expect(r.dropped).toEqual(["hero"])
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it("the name-keyed speaker survives the SLOT sanitizer — it names no slot to check", () => {
|
|
258
|
+
// `dropUnknownSpeakers` is the slot channel's sweep: it can only judge an
|
|
259
|
+
// id against the surviving slot list, and a cast NAME has no id space to be
|
|
260
|
+
// unknown in. Pinned so a later reader does not "fix" the asymmetry into a
|
|
261
|
+
// sweep that silently eats the field studio imports this type for.
|
|
262
|
+
const audio: AudioLayer[] = [{ mode: "speech", content: "hi", speaker: "Jack Mercer" }]
|
|
263
|
+
const r = dropUnknownSpeakers(audio, new Set())
|
|
264
|
+
expect(r.audio).toBe(audio)
|
|
265
|
+
expect(r.audio[0]!.speaker).toBe("Jack Mercer")
|
|
266
|
+
expect(r.dropped).toEqual([])
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("both attributions are OPTIONAL — an older producer emits neither", () => {
|
|
270
|
+
const parsed = windowAnalysisSchema.parse({ slots: [slot], scenes: [baseScene] })
|
|
271
|
+
expect(parsed.scenes[0]!.audio[0]).not.toHaveProperty("speakerSlot")
|
|
272
|
+
expect(parsed.scenes[0]!.audio[0]).not.toHaveProperty("speaker")
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
221
276
|
describe("cinematography fields (angle / speed / onScreenText / look)", () => {
|
|
222
277
|
it("carries angle and speed as closed enums through a window round-trip", () => {
|
|
223
278
|
const parsed = windowAnalysisSchema.parse({
|
|
@@ -348,6 +403,34 @@ describe("cinematography fields (angle / speed / onScreenText / look)", () => {
|
|
|
348
403
|
expect(r.look?.format).toBe("anamorphic digital")
|
|
349
404
|
expect(r.meta).not.toHaveProperty("look")
|
|
350
405
|
})
|
|
406
|
+
|
|
407
|
+
it("look carries the Style picker's own ID beside the prose — the PICK must not be stripped", () => {
|
|
408
|
+
// The analyzer emits the id it PICKED from the Style catalog alongside the
|
|
409
|
+
// prose it corresponds to: { styleId: "pixar-3d", style: "3D stylized
|
|
410
|
+
// animation", influence: "Pixar style" }. A z.object drops what it does not
|
|
411
|
+
// declare, so an undeclared `styleId` silently loses the pick on every
|
|
412
|
+
// consumer that reads an analysis back THROUGH this schema — and a pick is
|
|
413
|
+
// worth strictly more than the prose, because it addresses the catalog.
|
|
414
|
+
const look = { styleId: "pixar-3d", style: "3D stylized animation", influence: "Pixar style" }
|
|
415
|
+
// The window layer, where the pick enters…
|
|
416
|
+
expect(windowAnalysisSchema.parse({ look, slots: [], scenes: [] }).look).toMatchObject(look)
|
|
417
|
+
// …and the merged result, where every consumer reads it.
|
|
418
|
+
const r = videoAnalysisResultSchema.parse({
|
|
419
|
+
meta: { durationSec: 10, width: 1920, height: 1080, aspectRatio: "16:9" },
|
|
420
|
+
look,
|
|
421
|
+
slots: [],
|
|
422
|
+
scenes: [{ ...baseScene, sceneNumber: 1, visualResolved: "x", slotRefs: [] }],
|
|
423
|
+
})
|
|
424
|
+
expect(r.look).toMatchObject(look)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it("mergeClipLook folds the pick like any other field — first window to read it wins", () => {
|
|
428
|
+
// The fold is generic (Object.entries), so this only breaks if someone
|
|
429
|
+
// narrows it to a hand-written field list — the hop that has eaten a field
|
|
430
|
+
// before.
|
|
431
|
+
expect(mergeClipLook([{ style: "3D stylized animation" }, { styleId: "pixar-3d" }]))
|
|
432
|
+
.toEqual({ style: "3D stylized animation", styleId: "pixar-3d" })
|
|
433
|
+
})
|
|
351
434
|
})
|
|
352
435
|
|
|
353
436
|
describe("mergeClipLook", () => {
|
|
@@ -78,16 +78,37 @@ describe("getVideoAudioCapability", () => {
|
|
|
78
78
|
}
|
|
79
79
|
})
|
|
80
80
|
|
|
81
|
+
it("returns ambient (always on) for both Gemini Omni SKUs", () => {
|
|
82
|
+
// Google's own docs: "By default the model will try to generate an
|
|
83
|
+
// appropriate audio track for a video." Not `none` — the catalog has said
|
|
84
|
+
// "native audio" since the SKUs landed, and this map was the one place that
|
|
85
|
+
// disagreed, so every reader of it described the models as silent.
|
|
86
|
+
//
|
|
87
|
+
// Not `native_speech` either: the SAME page scopes dialogue to a path we do
|
|
88
|
+
// not drive — "Multi-turn voice extension: Generating spoken dialogue or
|
|
89
|
+
// speech is supported when extending previously generated videos via
|
|
90
|
+
// multi-turn (`previous_interaction_id`)" — and KIE's createTask schema
|
|
91
|
+
// exposes no such field. Same bar Wan 3.0 was held to.
|
|
92
|
+
for (const m of ["gemini-omni-video", "gemini-omni-flash"]) {
|
|
93
|
+
const cap = getVideoAudioCapability(m)
|
|
94
|
+
expect(cap.mode, m).toBe("ambient")
|
|
95
|
+
// No on/off parameter anywhere in the KIE input schema ⇒ alwaysOn, no field.
|
|
96
|
+
expect(cap.alwaysOn, m).toBe(true)
|
|
97
|
+
expect(cap.field, m).toBeUndefined()
|
|
98
|
+
expect(cap.affectsCost, m).toBeUndefined()
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("the two Omni SKUs never disagree — they are one model at two speeds", () => {
|
|
103
|
+
expect(VIDEO_AUDIO_CAPABILITY["gemini-omni-flash"]).toEqual(VIDEO_AUDIO_CAPABILITY["gemini-omni-video"])
|
|
104
|
+
})
|
|
105
|
+
|
|
81
106
|
it("defaults to none for silent / unknown / undefined models", () => {
|
|
82
107
|
for (const m of [
|
|
83
108
|
"minimax",
|
|
84
109
|
"hailuo-2.3",
|
|
85
110
|
"wan-i2v",
|
|
86
111
|
"grok-i2v",
|
|
87
|
-
"gemini-omni-video",
|
|
88
|
-
// Gemini Omni Flash mirrors its sibling: deliberately unlisted, so both
|
|
89
|
-
// Omni SKUs report the same audio capability.
|
|
90
|
-
"gemini-omni-flash",
|
|
91
112
|
"runway",
|
|
92
113
|
"pika",
|
|
93
114
|
"totally-unknown-model",
|
|
@@ -105,6 +126,8 @@ describe("videoModelSupportsAudio", () => {
|
|
|
105
126
|
expect(videoModelSupportsAudio("kling-3-omni")).toBe(true)
|
|
106
127
|
expect(videoModelSupportsAudio("seedance-2")).toBe(true)
|
|
107
128
|
expect(videoModelSupportsAudio("seedance")).toBe(true)
|
|
129
|
+
expect(videoModelSupportsAudio("gemini-omni-video")).toBe(true)
|
|
130
|
+
expect(videoModelSupportsAudio("gemini-omni-flash")).toBe(true)
|
|
108
131
|
expect(videoModelSupportsAudio("minimax")).toBe(false)
|
|
109
132
|
expect(videoModelSupportsAudio(undefined)).toBe(false)
|
|
110
133
|
})
|
|
@@ -124,6 +147,10 @@ describe("videoModelCanSpeakDialogue", () => {
|
|
|
124
147
|
expect(videoModelCanSpeakDialogue("kling-3-omni")).toBe(true)
|
|
125
148
|
// ambient-only models are NOT dialogue-capable — their audio is SFX/ambient
|
|
126
149
|
expect(videoModelCanSpeakDialogue("seedance")).toBe(false)
|
|
150
|
+
// Gemini Omni generates an audio track on every render, but its documented
|
|
151
|
+
// dialogue path is multi-turn extension, which our transport cannot reach.
|
|
152
|
+
expect(videoModelCanSpeakDialogue("gemini-omni-video")).toBe(false)
|
|
153
|
+
expect(videoModelCanSpeakDialogue("gemini-omni-flash")).toBe(false)
|
|
127
154
|
expect(videoModelCanSpeakDialogue("minimax")).toBe(false)
|
|
128
155
|
expect(videoModelCanSpeakDialogue(undefined)).toBe(false)
|
|
129
156
|
})
|
|
@@ -260,6 +287,13 @@ describe("applyVideoAudioToggle — neutral audio intent → per-model KIE field
|
|
|
260
287
|
expect(input).toEqual({})
|
|
261
288
|
})
|
|
262
289
|
|
|
290
|
+
it("is a no-op for always-on Gemini Omni — the KIE schema has no audio lever", () => {
|
|
291
|
+
const input: Record<string, unknown> = {}
|
|
292
|
+
applyVideoAudioToggle(input, "gemini-omni-video", { sound: false })
|
|
293
|
+
applyVideoAudioToggle(input, "gemini-omni-flash", { generateAudio: true })
|
|
294
|
+
expect(input).toEqual({})
|
|
295
|
+
})
|
|
296
|
+
|
|
263
297
|
it("is a no-op for silent / unknown models (not in the capability table)", () => {
|
|
264
298
|
const input: Record<string, unknown> = {}
|
|
265
299
|
applyVideoAudioToggle(input, "minimax", { sound: true })
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
MODEL_CATALOG,
|
|
4
|
+
resolutionOptionsByKind,
|
|
5
|
+
aspectRatioOptionsByKind,
|
|
6
|
+
durationsByMode,
|
|
7
|
+
} from "../model-catalog.js"
|
|
8
|
+
import { VIDEO_GEN_PROVIDERS } from "../model-constants.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every video model a user can pick must have a catalog entry. The catalog is
|
|
12
|
+
* what `normalizeModelInput` / `normalizeVideoRequestParams` snap against, what
|
|
13
|
+
* `VIDEO_PROVIDERS_REQUIRING_IMAGE` derives from, and what the frontend option
|
|
14
|
+
* menus are built from — a model outside it silently opts out of all three and
|
|
15
|
+
* gets its option lists hand-spliced into the frontend instead. That is exactly
|
|
16
|
+
* how ltx-2.3-fast reached Replicate with a 720p resolution and a 422 came back
|
|
17
|
+
* (app-reports triage 2026-09-01, P3).
|
|
18
|
+
*/
|
|
19
|
+
describe("MODEL_CATALOG covers every generatable video provider", () => {
|
|
20
|
+
it("has an entry for every VIDEO_GEN_PROVIDERS member", () => {
|
|
21
|
+
const missing = VIDEO_GEN_PROVIDERS.filter((p) => !MODEL_CATALOG[p])
|
|
22
|
+
expect(
|
|
23
|
+
missing,
|
|
24
|
+
`Add a MODEL_CATALOG entry (modes / aspectRatios / resolutions / durations / pricing) for: ${missing.join(", ")}`,
|
|
25
|
+
).toEqual([])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// Deliberately a KEY-PRESENCE check, not `kind === "video"`. `grok`'s entry
|
|
29
|
+
// lives in IMAGE_MODELS with `kind: "image"` (it is a t2v provider whose
|
|
30
|
+
// catalog row is the image sibling), so tightening this assertion would fail
|
|
31
|
+
// on `grok` for a reason unrelated to LTX. Fix that entry first if you ever
|
|
32
|
+
// want the stronger form.
|
|
33
|
+
|
|
34
|
+
it("every video entry's pricing identifiers are unique and non-empty", () => {
|
|
35
|
+
for (const p of VIDEO_GEN_PROVIDERS) {
|
|
36
|
+
const entry = MODEL_CATALOG[p]
|
|
37
|
+
if (!entry) continue
|
|
38
|
+
const ids = entry.pricing.map((v) => v.identifier)
|
|
39
|
+
expect(ids.length, `${p} has no pricing rows`).toBeGreaterThan(0)
|
|
40
|
+
expect(new Set(ids).size, `${p} has duplicate pricing identifiers`).toBe(ids.length)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Deleting the LTX hand-splices in frontend/model-options.ts (Task 7 step 5)
|
|
47
|
+
* hands resolution/aspect/duration option rendering to these catalog-derived
|
|
48
|
+
* helpers. Pin their LTX output to exactly what the deleted splices used to
|
|
49
|
+
* hard-code, so the refactor is provably UI-neutral — including the "2k"
|
|
50
|
+
* label regression (R24) the brief called out: MODEL_VALUE_LABELS had no
|
|
51
|
+
* "2k" entry, so the derived option rendered lowercase "2k" where the spliced
|
|
52
|
+
* literal rendered "2K" until that label was added.
|
|
53
|
+
*/
|
|
54
|
+
describe("LTX catalog-derived options match the deleted frontend splices exactly", () => {
|
|
55
|
+
const RESOLUTION_OPTIONS = [
|
|
56
|
+
{ value: "1080p", label: "1080p" },
|
|
57
|
+
{ value: "2k", label: "2K" },
|
|
58
|
+
{ value: "4k", label: "4K" },
|
|
59
|
+
]
|
|
60
|
+
const ASPECT_OPTIONS = [
|
|
61
|
+
{ value: "16:9", label: "16:9 (Landscape)" },
|
|
62
|
+
{ value: "9:16", label: "9:16 (Portrait)" },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
it("resolutionOptionsByKind('video') renders both LTX ids identically to the deleted VIDEO_RESOLUTION_OPTIONS splice", () => {
|
|
66
|
+
const options = resolutionOptionsByKind("video")
|
|
67
|
+
expect(options["ltx-2.3-pro"]).toEqual(RESOLUTION_OPTIONS)
|
|
68
|
+
expect(options["ltx-2.3-fast"]).toEqual(RESOLUTION_OPTIONS)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("aspectRatioOptionsByKind('video') renders both LTX ids identically to the deleted _VIDEO_ASPECT_BY_PROVIDER splice", () => {
|
|
72
|
+
const options = aspectRatioOptionsByKind("video")
|
|
73
|
+
expect(options["ltx-2.3-pro"]).toEqual(ASPECT_OPTIONS)
|
|
74
|
+
expect(options["ltx-2.3-fast"]).toEqual(ASPECT_OPTIONS)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("durationsByMode merges to the same duration lists the deleted out[\"ltx-2.3-*\"] splice hard-coded", () => {
|
|
78
|
+
const i2v = durationsByMode("i2v")
|
|
79
|
+
const t2v = durationsByMode("t2v")
|
|
80
|
+
const merge = (id: string) =>
|
|
81
|
+
Array.from(new Set([...(i2v[id] ?? []), ...(t2v[id] ?? [])])).sort((a, b) => a - b)
|
|
82
|
+
expect(merge("ltx-2.3-pro")).toEqual([6, 8, 10])
|
|
83
|
+
expect(merge("ltx-2.3-fast")).toEqual([6, 8, 10, 12, 14, 16, 18, 20])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { MODEL_CATALOG, normalizeVideoRequestParams } from "../index.js"
|
|
3
|
+
import {
|
|
4
|
+
normalizeMinimaxH3Resolution,
|
|
5
|
+
normalizeWan3Resolution,
|
|
6
|
+
VIDEO_GEN_PROVIDERS,
|
|
7
|
+
} from "../model-constants.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Most video providers RENDER whatever band they are handed, so an off-list
|
|
11
|
+
* request is best snapped to the nearest supported one. A few instead COLLAPSE
|
|
12
|
+
* anything unrecognised to a fixed default, and for those the nearest band is
|
|
13
|
+
* wrong in the most expensive direction: MiniMax H3 renders 2K for every value
|
|
14
|
+
* that isn't "768p", so snapping a stale Seedance "720p" to the pixel-nearest
|
|
15
|
+
* 768P would bill the CHEAP tier against a 2K render — and `commit_credits`
|
|
16
|
+
* (migration 176) only refunds a surplus, never collects a shortfall.
|
|
17
|
+
*
|
|
18
|
+
* `ModelCatalogEntry.unlistedResolutionRendersAs` is how a model declares that
|
|
19
|
+
* behaviour. These tests pin every declaration to what the provider's OWN
|
|
20
|
+
* normalizer returns, so a change to one without the other fails the build
|
|
21
|
+
* rather than silently repricing live runs.
|
|
22
|
+
*/
|
|
23
|
+
describe("unlistedResolutionRendersAs matches the provider's own collapse rule", () => {
|
|
24
|
+
// Real off-list VALUES only. A blank/absent resolution is a different case —
|
|
25
|
+
// it never reaches the snap (there is nothing to snap), and the identifier
|
|
26
|
+
// already prices it through the provider's own collapse rule, so price and
|
|
27
|
+
// render agree on it without this field.
|
|
28
|
+
const OFF_LIST = ["720p", "480p", "1080p", "4k", "nonsense"]
|
|
29
|
+
|
|
30
|
+
it("minimax-h3 declares what normalizeMinimaxH3Resolution collapses to", () => {
|
|
31
|
+
const declared = MODEL_CATALOG["minimax-h3"]!.unlistedResolutionRendersAs
|
|
32
|
+
expect(declared).toBeDefined()
|
|
33
|
+
for (const off of OFF_LIST) {
|
|
34
|
+
if ((MODEL_CATALOG["minimax-h3"]!.resolutions as readonly string[]).includes(off)) continue
|
|
35
|
+
expect(normalizeMinimaxH3Resolution(off), `H3 renders ${off} as`).toBe(declared)
|
|
36
|
+
expect(normalizeVideoRequestParams("minimax-h3", { resolution: off }).resolution).toBe(declared)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("the wan-3 family declares what normalizeWan3Resolution collapses to", () => {
|
|
41
|
+
for (const id of ["wan-3", "wan-3-prime"]) {
|
|
42
|
+
const declared = MODEL_CATALOG[id]!.unlistedResolutionRendersAs
|
|
43
|
+
expect(declared, id).toBeDefined()
|
|
44
|
+
for (const off of ["4k", "2k", "nonsense"]) {
|
|
45
|
+
// The catalog spells bands lowercase; the KIE wire form is uppercase.
|
|
46
|
+
expect(normalizeWan3Resolution(off).toLowerCase(), `${id} renders ${off} as`).toBe(declared)
|
|
47
|
+
expect(normalizeVideoRequestParams(id, { resolution: off }).resolution).toBe(declared)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("a declared collapse target is always one of the model's own bands", () => {
|
|
53
|
+
for (const provider of VIDEO_GEN_PROVIDERS) {
|
|
54
|
+
const entry = MODEL_CATALOG[provider]
|
|
55
|
+
const declared = entry?.unlistedResolutionRendersAs
|
|
56
|
+
if (declared === undefined) continue
|
|
57
|
+
expect(entry!.resolutions, `${provider} declares a collapse target but no resolutions`).toBeDefined()
|
|
58
|
+
expect(entry!.resolutions, `${provider}: "${declared}" is not one of its bands`).toContain(declared)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("does not change a listed value — the collapse rule only governs OFF-list requests", () => {
|
|
63
|
+
expect(normalizeVideoRequestParams("minimax-h3", { resolution: "768P" }).resolution).toBe("768P")
|
|
64
|
+
expect(normalizeVideoRequestParams("minimax-h3", { resolution: "2K" }).resolution).toBe("2K")
|
|
65
|
+
expect(normalizeVideoRequestParams("wan-3", { resolution: "480p" }).resolution).toBe("480p")
|
|
66
|
+
expect(normalizeVideoRequestParams("wan-3", { resolution: "1080p" }).resolution).toBe("1080p")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("every other video model still snaps to the NEAREST band", () => {
|
|
70
|
+
// The collapse rule is an opt-in exception, not the default: a 4k request on
|
|
71
|
+
// a 1080p-max model must still render 1080p, never the cheapest tier (R7).
|
|
72
|
+
expect(MODEL_CATALOG["seedance-2-5"]!.unlistedResolutionRendersAs).toBeUndefined()
|
|
73
|
+
expect(normalizeVideoRequestParams("seedance-2-5", { resolution: "4k" }).resolution).toBe("1080p")
|
|
74
|
+
expect(normalizeVideoRequestParams("seedance-2-5", { resolution: "360p" }).resolution).toBe("480p")
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -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
|
+
})
|