@nodaro/shared 2.20.0 → 2.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +458 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +368 -8
- package/dist/index.d.ts +368 -8
- package/dist/index.js +447 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/safety-retry-policy.test.ts +62 -0
- package/src/__tests__/topaz-upscale.test.ts +132 -0
- package/src/__tests__/unresolved-ref-tokens.test.ts +66 -0
- package/src/__tests__/video-analysis.test.ts +83 -0
- package/src/__tests__/video-audio-capability.test.ts +38 -4
- package/src/__tests__/video-catalog-totality.test.ts +85 -0
- package/src/__tests__/video-collapse-parity.test.ts +76 -0
- package/src/__tests__/video-ref-video-duration-limits.test.ts +69 -0
- package/src/__tests__/video-request-normalize.test.ts +239 -0
- package/src/credit-identifiers.ts +115 -8
- package/src/index.ts +23 -3
- package/src/model-catalog.ts +288 -6
- package/src/model-constants.ts +116 -4
- package/src/node-refs.ts +82 -0
- package/src/node-runtime-keys.ts +13 -0
- package/src/safety-retry-policy.ts +37 -0
- package/src/topaz-upscale.ts +163 -0
- package/src/video-analysis.ts +70 -5
package/package.json
CHANGED
|
@@ -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
|
+
})
|