@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.20.0",
3
+ "version": "2.21.1",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,62 @@
1
+ import { T2I_TO_I2I_VARIANT } from "../model-constants.js"
2
+ import { describe, it, expect } from "vitest"
3
+ import { safetyRetryPolicy } from "../safety-retry-policy.js"
4
+ import { MODEL_CATALOG, getModel } from "../model-catalog.js"
5
+
6
+ describe("safetyRetryPolicy", () => {
7
+ it("gives gpt-image-2 a second attempt and names its fallback", () => {
8
+ expect(safetyRetryPolicy("gpt-image-2")).toEqual({
9
+ maxAttempts: 2,
10
+ fallback: "nano-banana-pro",
11
+ })
12
+ })
13
+
14
+ it("gives a model without the flag a single attempt and no fallback", () => {
15
+ // nano-banana-pro does not declare `safetyFilter` itself.
16
+ expect(safetyRetryPolicy("nano-banana-pro")).toEqual({ maxAttempts: 1 })
17
+ })
18
+
19
+ it("gives an unknown model id a single attempt and no fallback", () => {
20
+ expect(safetyRetryPolicy("totally-not-a-real-model-id")).toEqual({ maxAttempts: 1 })
21
+ })
22
+
23
+ it("every declared fallback resolves to a catalog entry that can actually cover the flagged model", () => {
24
+ const flagged = Object.values(MODEL_CATALOG).filter((m) => m.safetyFilter?.fallback)
25
+ // Sanity: this guard is only meaningful if at least one entry exercises it.
26
+ expect(flagged.length).toBeGreaterThan(0)
27
+
28
+ for (const entry of flagged) {
29
+ const fallbackId = entry.safetyFilter!.fallback!
30
+ const fallbackEntry = getModel(fallbackId)
31
+
32
+ expect(fallbackEntry, `${entry.id} declares fallback "${fallbackId}" which is not a catalog entry`).toBeDefined()
33
+ expect(fallbackEntry!.kind, `${entry.id}'s fallback "${fallbackId}" does not produce an image`).toBe("image")
34
+
35
+ for (const mode of entry.modes) {
36
+ expect(
37
+ fallbackEntry!.modes,
38
+ `${entry.id}'s fallback "${fallbackId}" is missing mode "${mode}" that ${entry.id} supports`,
39
+ ).toContain(mode)
40
+ }
41
+
42
+ expect(
43
+ fallbackEntry!.features ?? [],
44
+ `${entry.id}'s fallback "${fallbackId}" does not accept a reference image`,
45
+ ).toContain("reference-image")
46
+ }
47
+ })
48
+ })
49
+
50
+ describe("i2i variants inherit their base model's safety policy", () => {
51
+ it("resolves the referenced-request id (gpt-image-2-i2i) to the same policy as gpt-image-2", () => {
52
+ expect(safetyRetryPolicy("gpt-image-2-i2i")).toEqual({ maxAttempts: 2, fallback: "nano-banana-pro" })
53
+ })
54
+
55
+ it("every T2I_TO_I2I_VARIANT pair declares the same safetyFilter on both sides", () => {
56
+ for (const [t2i, i2i] of Object.entries(T2I_TO_I2I_VARIANT)) {
57
+ const base = MODEL_CATALOG[t2i]?.safetyFilter ?? null
58
+ const twin = MODEL_CATALOG[i2i]?.safetyFilter ?? null
59
+ expect({ pair: `${t2i} -> ${i2i}`, twin }).toEqual({ pair: `${t2i} -> ${i2i}`, twin: base })
60
+ }
61
+ })
62
+ })
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { resolveTopazUpscale, buildCreditModelIdentifier } from "../index.js"
3
+
4
+ describe("resolveTopazUpscale", () => {
5
+ it("defaults to the provider's own default factor when nothing is set", () => {
6
+ const r = resolveTopazUpscale({})
7
+ expect(r.upscaleFactor).toBe("2")
8
+ expect(r.creditTier).toBeUndefined()
9
+ expect(r.adjustments).toEqual([])
10
+ })
11
+
12
+ it("takes an explicit factor as the lever and prices it", () => {
13
+ expect(resolveTopazUpscale({ upscaleFactor: "1" })).toMatchObject({ upscaleFactor: "1", creditTier: undefined })
14
+ expect(resolveTopazUpscale({ upscaleFactor: "2" })).toMatchObject({ upscaleFactor: "2", creditTier: undefined })
15
+ expect(resolveTopazUpscale({ upscaleFactor: "4" })).toMatchObject({ upscaleFactor: "4", creditTier: "4K" })
16
+ })
17
+
18
+ it("an explicit factor wins over a legacy targetResolution (the factor is what we send)", () => {
19
+ const r = resolveTopazUpscale({ upscaleFactor: "2", targetResolution: "4K" })
20
+ expect(r.upscaleFactor).toBe("2")
21
+ expect(r.creditTier).toBeUndefined()
22
+ })
23
+
24
+ it("maps a legacy targetResolution forward when no factor is set", () => {
25
+ expect(resolveTopazUpscale({ targetResolution: "2K" })).toMatchObject({ upscaleFactor: "2", creditTier: undefined })
26
+ expect(resolveTopazUpscale({ targetResolution: "4K" })).toMatchObject({ upscaleFactor: "4", creditTier: "4K" })
27
+ })
28
+
29
+ it("caps a legacy 8K request at the 4x the provider actually offers, and says so", () => {
30
+ const r = resolveTopazUpscale({ targetResolution: "8K" })
31
+ expect(r.upscaleFactor).toBe("4")
32
+ expect(r.creditTier).toBe("4K")
33
+ expect(r.adjustments).toHaveLength(1)
34
+ expect(r.adjustments[0]).toMatchObject({ field: "targetResolution", from: "8K", to: "4" })
35
+ })
36
+
37
+ it("coerces an out-of-enum factor and records the adjustment", () => {
38
+ const r = resolveTopazUpscale({ upscaleFactor: "8" })
39
+ expect(r.upscaleFactor).toBe("2")
40
+ expect(r.adjustments[0]).toMatchObject({ field: "upscaleFactor", from: "8", to: "2" })
41
+ })
42
+
43
+ it("every resolution it can return prices through the existing composites", () => {
44
+ for (const input of [
45
+ {}, { upscaleFactor: "1" }, { upscaleFactor: "2" }, { upscaleFactor: "4" },
46
+ { targetResolution: "2K" }, { targetResolution: "4K" }, { targetResolution: "8K" },
47
+ ]) {
48
+ const { creditTier } = resolveTopazUpscale(input)
49
+ const id = buildCreditModelIdentifier("topaz-image-upscale", undefined, undefined, undefined, creditTier)
50
+ expect(["topaz-image-upscale", "topaz-image-upscale:4K"]).toContain(id)
51
+ }
52
+ })
53
+
54
+ // --- Fix round 1 (2026-09-02 review): factor resolved before adjustments
55
+ // are built, `to` always pinned to the resolved factor, `from` always the
56
+ // raw untransformed input, and a valid factor's override of a stored
57
+ // targetResolution is disclosed rather than silently dropped. ---
58
+
59
+ it("an invalid factor still lets a legacy tier win, and reports the REAL resolved factor", () => {
60
+ const r = resolveTopazUpscale({ upscaleFactor: "8", targetResolution: "4K" })
61
+ expect(r.upscaleFactor).toBe("4")
62
+ expect(r.creditTier).toBe("4K")
63
+ const factorAdj = r.adjustments.find((a) => a.field === "upscaleFactor")
64
+ expect(factorAdj).toMatchObject({ field: "upscaleFactor", from: "8", to: "4" })
65
+ })
66
+
67
+ it("a valid factor overriding a disagreeing legacy tier discloses the override", () => {
68
+ const r = resolveTopazUpscale({ upscaleFactor: "2", targetResolution: "8K" })
69
+ expect(r.upscaleFactor).toBe("2")
70
+ const tierAdj = r.adjustments.find((a) => a.field === "targetResolution")
71
+ expect(tierAdj).toBeDefined()
72
+ expect(tierAdj).toMatchObject({ field: "targetResolution", from: "8K", to: undefined })
73
+ })
74
+
75
+ it("a valid factor that agrees with the legacy tier's factor discloses nothing", () => {
76
+ const r = resolveTopazUpscale({ upscaleFactor: "4", targetResolution: "4K" })
77
+ expect(r.upscaleFactor).toBe("4")
78
+ expect(r.adjustments).toEqual([])
79
+ })
80
+
81
+ it("an unknown legacy tier alone falls back to the default and reports the raw token", () => {
82
+ const r = resolveTopazUpscale({ targetResolution: "1080p" })
83
+ expect(r.upscaleFactor).toBe("2")
84
+ expect(r.creditTier).toBeUndefined()
85
+ expect(r.adjustments).toHaveLength(1)
86
+ expect(r.adjustments[0]).toMatchObject({ field: "targetResolution", from: "1080p", to: "2" })
87
+ })
88
+
89
+ const FACTOR_INPUTS: Array<string | undefined> = [undefined, "2", "4", "8", "foo", " 4 ", "4k"]
90
+ const TIER_INPUTS: Array<string | null | undefined> = [undefined, "2K", "4K", "8K", "4k", "1080p", null]
91
+
92
+ describe("factor x targetResolution table", () => {
93
+ for (const upscaleFactor of FACTOR_INPUTS) {
94
+ for (const targetResolution of TIER_INPUTS) {
95
+ const label = `upscaleFactor=${JSON.stringify(upscaleFactor)} targetResolution=${JSON.stringify(targetResolution)}`
96
+ it(`${label} — adjustments never disclose a factor other than the resolved one`, () => {
97
+ const result = resolveTopazUpscale({ upscaleFactor, targetResolution })
98
+
99
+ // Every field:"upscaleFactor" adjustment must announce the factor
100
+ // that was ACTUALLY resolved — never an intermediate guess.
101
+ expect(
102
+ result.adjustments.every((a) => a.field !== "upscaleFactor" || a.to === result.upscaleFactor),
103
+ ).toBe(true)
104
+
105
+ // `from` is always the raw, untransformed input — never trimmed or
106
+ // case-normalized — for both adjustment kinds.
107
+ for (const adj of result.adjustments) {
108
+ if (adj.field === "upscaleFactor") {
109
+ expect(adj.from).toBe(upscaleFactor)
110
+ } else {
111
+ expect(adj.from).toBe(targetResolution)
112
+ }
113
+ }
114
+
115
+ // The resolved factor and credit tier are always internally consistent.
116
+ expect(result.creditTier).toBe(result.upscaleFactor === "4" ? "4K" : undefined)
117
+
118
+ // Every resolution this function can produce must still price
119
+ // through an existing composite identifier.
120
+ const id = buildCreditModelIdentifier(
121
+ "topaz-image-upscale",
122
+ undefined,
123
+ undefined,
124
+ undefined,
125
+ result.creditTier,
126
+ )
127
+ expect(["topaz-image-upscale", "topaz-image-upscale:4K"]).toContain(id)
128
+ })
129
+ }
130
+ }
131
+ })
132
+ })
@@ -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
+ })
@@ -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
+ })