@nodaro/shared 2.1.0 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
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,65 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { GVP_ANCHOR_CHOICES, resolveGvpAnchorWire, type GvpAnchorChoice } from "../model-constants.js"
3
+
4
+ /**
5
+ * The keyframes anchor lever: the node stores a product choice, the plugin
6
+ * wire speaks its own chain-mode vocabulary, and `resolveGvpAnchorWire` is the
7
+ * ONE translation both send paths (canvas Run, DAG payload-builder) share.
8
+ *
9
+ * The invariant these tests defend is that the two vocabularies stay wired
10
+ * together — a choice added to `GVP_ANCHOR_CHOICES` without a matching switch
11
+ * case would otherwise fall through to `undefined` and silently render as
12
+ * "auto", which looks exactly like a working feature until someone compares
13
+ * two renders.
14
+ */
15
+ describe("resolveGvpAnchorWire", () => {
16
+ it("sends nothing for auto, so payloads stay byte-identical to pre-lever runs", () => {
17
+ expect(resolveGvpAnchorWire("auto")).toBeUndefined()
18
+ expect(resolveGvpAnchorWire(undefined)).toBeUndefined()
19
+ })
20
+
21
+ it("maps each explicit choice to its engine chain mode", () => {
22
+ expect(resolveGvpAnchorWire("start-end")).toBe("upfront")
23
+ expect(resolveGvpAnchorWire("start-only")).toBe("progressive")
24
+ expect(resolveGvpAnchorWire("reference")).toBe("none")
25
+ })
26
+
27
+ it("resolves EVERY non-auto choice to a defined mode (totality guard)", () => {
28
+ // Adding a choice to GVP_ANCHOR_CHOICES without extending the switch fails
29
+ // here rather than shipping a dropdown entry that quietly does nothing.
30
+ for (const choice of GVP_ANCHOR_CHOICES) {
31
+ const wire = resolveGvpAnchorWire(choice)
32
+ if (choice === "auto") expect(wire, choice).toBeUndefined()
33
+ else expect(wire, choice).toBeDefined()
34
+ }
35
+ })
36
+
37
+ it("only ever emits vocabulary the plugin route's enum accepts", () => {
38
+ // Mirrors `anchorMode: z.enum(["upfront","progressive","none"])` in the
39
+ // plugin's generate-video-pro route — a value outside it is a 400 on every
40
+ // run, so the mapping is pinned here as well as at the call sites.
41
+ const accepted = new Set(["upfront", "progressive", "none"])
42
+ for (const choice of GVP_ANCHOR_CHOICES) {
43
+ const wire = resolveGvpAnchorWire(choice)
44
+ if (wire !== undefined) expect(accepted.has(wire), `${choice} → ${wire}`).toBe(true)
45
+ }
46
+ })
47
+
48
+ it("treats unknown/stale stored values as auto rather than guessing", () => {
49
+ // Node data is user-editable JSON and survives across releases; a value
50
+ // this build has never heard of must degrade to the engine default, never
51
+ // to an arbitrary mode that would bill a different render.
52
+ for (const stale of ["", "upfront", "progressive", "none", "start_end", "nonsense"]) {
53
+ expect(resolveGvpAnchorWire(stale), stale).toBeUndefined()
54
+ }
55
+ })
56
+
57
+ it("keeps auto first, so the default choice heads the panel dropdown", () => {
58
+ expect(GVP_ANCHOR_CHOICES[0]).toBe("auto")
59
+ })
60
+
61
+ it("exposes the choice list as the single runtime source for the type", () => {
62
+ const choices: readonly GvpAnchorChoice[] = GVP_ANCHOR_CHOICES
63
+ expect(new Set(choices).size).toBe(GVP_ANCHOR_CHOICES.length)
64
+ })
65
+ })
@@ -1,31 +1,250 @@
1
1
  import { describe, it, expect } from "vitest"
2
- import { GVP_SUPPORTED_PROVIDERS, isGvpSupportedProvider, isSeedance2Provider } from "../model-constants.js"
2
+ import {
3
+ GVP_SUPPORTED_PROVIDERS,
4
+ GVP_DEFAULT_PROVIDER,
5
+ isGvpSupportedProvider,
6
+ GVP_EXTEND_PROVIDERS,
7
+ supportsExtendRender,
8
+ GVP_END_FRAME_PROVIDERS,
9
+ supportsEndAnchor,
10
+ segmentDurationsFor,
11
+ minSegmentSecFor,
12
+ maxSegmentSecFor,
13
+ hasContiguousSegmentDurations,
14
+ maxSegmentsFor,
15
+ VIDEO_REF_LIMITS_BY_PROVIDER,
16
+ VIDEO_PROVIDERS_WITHOUT_DISPATCH,
17
+ isSeedance2Provider,
18
+ isMinimaxH3Provider,
19
+ } from "../model-constants.js"
20
+ import { MODEL_CATALOG } from "../model-catalog.js"
3
21
 
4
22
  /**
5
- * Generate/Edit Video Pro provider-selection guard (2026-07-21): the pro
6
- * nodes offer ONLY these SKUs. The list is intentionally narrower than the
7
- * SEEDANCE_2_PROVIDERS capability family (mini is a Seedance-2 variant for
8
- * capability gating but is NOT offered by the pro engine). If a new SKU is
9
- * blessed for the pro nodes, update this pin together with the docs pages
10
- * (docs/nodes/ai-video/generate-video-pro.md, edit-video-pro.md).
23
+ * Generate Video Pro provider selection (2026-08-05: DERIVED, was a hand-kept
24
+ * literal).
25
+ *
26
+ * The bar is what the pro engine's KEYFRAMES render method minimally needs —
27
+ * a model that takes a generated start still and can carry reference images.
28
+ * Extend (the r2v continuation chain) is a strictly narrower, separately
29
+ * derived tier.
30
+ *
31
+ * The old pin asserted "every blessed SKU is Seedance-2 or MiniMax-H3", which
32
+ * encoded the real invariant *blessed ⇒ rides the shared input resolver*. That
33
+ * invariant did not survive the two-tier transport split, so it is REPLACED
34
+ * here rather than deleted:
35
+ *
36
+ * blessed ⇒ i2v ∧ reference-image ∧ durations ∧ ∈ VIDEO_REF_LIMITS_BY_PROVIDER
37
+ * extend-eligible ⇒ features ∋ "video-reference" (⊂ blessed)
11
38
  */
12
39
  describe("GVP_SUPPORTED_PROVIDERS", () => {
13
- it("is exactly the blessed pro SKUs", () => {
14
- expect([...GVP_SUPPORTED_PROVIDERS]).toEqual(["seedance-2", "seedance-2-fast"])
40
+ it("is the capability derivation, and these are its members today", () => {
41
+ // Not a hand-kept list any more — this pin documents the CURRENT result so
42
+ // a catalog edit that changes the offered SKUs is visible in review, while
43
+ // the derivation itself is what ships.
44
+ expect([...GVP_SUPPORTED_PROVIDERS]).toEqual([
45
+ "seedance-2",
46
+ "seedance-2-fast",
47
+ "seedance-2-mini",
48
+ "seedance-2-5",
49
+ "minimax-h3",
50
+ "veo3",
51
+ "veo3.1",
52
+ "veo3_lite",
53
+ "gemini-omni-video",
54
+ "grok-i2v",
55
+ "happyhorse-ref2v",
56
+ ])
15
57
  })
16
58
 
17
- it("is a strict subset of the Seedance-2 capability family", () => {
59
+ it("lists the default SKU first (dropdowns render this order)", () => {
60
+ expect(GVP_SUPPORTED_PROVIDERS[0]).toBe(GVP_DEFAULT_PROVIDER)
61
+ })
62
+
63
+ it("every blessed SKU meets the keyframes bar", () => {
18
64
  for (const p of GVP_SUPPORTED_PROVIDERS) {
19
- expect(isSeedance2Provider(p)).toBe(true)
65
+ const m = MODEL_CATALOG[p]
66
+ expect(m, `${p} must be in the catalog`).toBeDefined()
67
+ expect(m!.kind).toBe("video")
68
+ expect(m!.modes as readonly string[]).toContain("i2v")
69
+ expect(m!.features ?? [], `${p} must accept reference images`).toContain("reference-image")
70
+ // Segmentable: a model with no declared durations cannot be split, and a
71
+ // silent 4-15s fallback would send lengths the provider rejects.
72
+ expect(segmentDurationsFor(p).length, `${p} must declare durations`).toBeGreaterThan(0)
73
+ }
74
+ })
75
+
76
+ /**
77
+ * The cross-check that makes the catalog flag trustworthy.
78
+ * VIDEO_REF_LIMITS_BY_PROVIDER was built by the 2026-06-28 audit of actual
79
+ * backend reference-FORWARDING paths (it deliberately omits wan-i2v /
80
+ * hailuo-2.3[-pro] / bytedance-pro[-fast] / grok-imagine-video-1.5, which
81
+ * take a single image_url start frame and drop refs on the floor). If the
82
+ * two signals ever disagree, one of them is lying about what the backend
83
+ * does — fail the build rather than advertise a dropped capability.
84
+ */
85
+ it("agrees with VIDEO_REF_LIMITS_BY_PROVIDER (the forwarding-path audit)", () => {
86
+ const withRefLimits = Object.keys(VIDEO_REF_LIMITS_BY_PROVIDER).filter(
87
+ (id) => MODEL_CATALOG[id]?.kind === "video" && !VIDEO_PROVIDERS_WITHOUT_DISPATCH.has(id),
88
+ )
89
+ expect([...GVP_SUPPORTED_PROVIDERS].sort()).toEqual(withRefLimits.sort())
90
+ })
91
+
92
+ /**
93
+ * Capability is necessary but NOT sufficient — a model also has to reach a
94
+ * provider. kling-3-omni passes every capability check (i2v, reference-image,
95
+ * 3-15s durations, a ref-limit row) and still fails at the router: its only
96
+ * implementation is on Replicate while the i2v routing chain is KIE-only,
97
+ * with no worker short-circuit. It was pulled from the frontend picker for
98
+ * exactly that reason, and the pro node must not re-admit it through the
99
+ * capability door.
100
+ */
101
+ it("excludes catalogued models that have no dispatch path", () => {
102
+ expect(VIDEO_PROVIDERS_WITHOUT_DISPATCH.has("kling-3-omni")).toBe(true)
103
+ // It would otherwise qualify: every capability check passes.
104
+ const m = MODEL_CATALOG["kling-3-omni"]!
105
+ expect(m.modes as readonly string[]).toContain("i2v")
106
+ expect(m.features ?? []).toContain("reference-image")
107
+ expect(VIDEO_REF_LIMITS_BY_PROVIDER["kling-3-omni"]).toBeDefined()
108
+ for (const id of VIDEO_PROVIDERS_WITHOUT_DISPATCH) {
109
+ expect(isGvpSupportedProvider(id), `${id} has no dispatch path`).toBe(false)
20
110
  }
21
- // mini stays in the family (capabilities) but out of pro selection
22
- expect(isSeedance2Provider("seedance-2-mini")).toBe(true)
23
- expect(isGvpSupportedProvider("seedance-2-mini")).toBe(false)
24
111
  })
25
112
 
26
113
  it("predicate matches the list and rejects outsiders", () => {
27
114
  for (const p of GVP_SUPPORTED_PROVIDERS) expect(isGvpSupportedProvider(p)).toBe(true)
28
- expect(isGvpSupportedProvider("veo3")).toBe(false)
115
+ // Start-frame-only i2v models: no reference-forwarding path, so no anchor
116
+ // identity refs and no cast continuity — deliberately out.
117
+ expect(isGvpSupportedProvider("wan-i2v")).toBe(false)
118
+ expect(isGvpSupportedProvider("hailuo-2.3-pro")).toBe(false)
119
+ expect(isGvpSupportedProvider("bytedance-pro")).toBe(false)
120
+ expect(isGvpSupportedProvider("grok-imagine-video-1.5")).toBe(false)
121
+ // Not i2v at all.
122
+ expect(isGvpSupportedProvider("seedance-2-extend")).toBe(false)
123
+ expect(isGvpSupportedProvider("runway-aleph")).toBe(false)
29
124
  expect(isGvpSupportedProvider(undefined)).toBe(false)
30
125
  })
31
126
  })
127
+
128
+ describe("GVP_EXTEND_PROVIDERS", () => {
129
+ it("is the r2v-capable subset, and equals the family gate it replaces", () => {
130
+ expect([...GVP_EXTEND_PROVIDERS]).toEqual([
131
+ "seedance-2",
132
+ "seedance-2-fast",
133
+ "seedance-2-mini",
134
+ "seedance-2-5",
135
+ "minimax-h3",
136
+ ])
137
+ // Behaviour-neutral swap: the derived set is exactly what the old
138
+ // hardcoded `isSeedance2Provider || isMinimaxH3Provider` gate admitted.
139
+ for (const p of GVP_EXTEND_PROVIDERS) {
140
+ expect(isSeedance2Provider(p) || isMinimaxH3Provider(p)).toBe(true)
141
+ }
142
+ })
143
+
144
+ it("is a strict subset of the blessed set", () => {
145
+ for (const p of GVP_EXTEND_PROVIDERS) expect(isGvpSupportedProvider(p)).toBe(true)
146
+ expect(GVP_EXTEND_PROVIDERS.length).toBeLessThan(GVP_SUPPORTED_PROVIDERS.length)
147
+ })
148
+
149
+ it("excludes gemini-omni-video — its video input is a V2V source, not a continuation ref", () => {
150
+ // runGeminiOmni takes `video_list` with a trim window, auto-determines the
151
+ // duration from the clip and prices flat per generation. That is a
152
+ // different transport from an r2v conditioning tail.
153
+ expect(isGvpSupportedProvider("gemini-omni-video")).toBe(true)
154
+ expect(supportsExtendRender("gemini-omni-video")).toBe(false)
155
+ })
156
+
157
+ it("keyframes-only SKUs reject extend", () => {
158
+ for (const p of ["veo3", "veo3.1", "veo3_lite", "gemini-omni-video", "grok-i2v", "happyhorse-ref2v"]) {
159
+ expect(isGvpSupportedProvider(p)).toBe(true)
160
+ expect(supportsExtendRender(p)).toBe(false)
161
+ }
162
+ expect(supportsExtendRender(undefined)).toBe(false)
163
+ })
164
+ })
165
+
166
+ describe("GVP_END_FRAME_PROVIDERS", () => {
167
+ it("is the end-frame-capable subset — the keyframes end-anchor gate", () => {
168
+ expect([...GVP_END_FRAME_PROVIDERS]).toEqual([
169
+ "seedance-2",
170
+ "seedance-2-fast",
171
+ "seedance-2-mini",
172
+ "seedance-2-5",
173
+ "minimax-h3",
174
+ "veo3",
175
+ "veo3.1",
176
+ "veo3_lite",
177
+ ])
178
+ })
179
+
180
+ it("covers minimax-h3 — the pre-existing bug this derivation fixes", () => {
181
+ // minimax-h3 shipped as a GVP SKU on 2026-08-02 but wantEndAnchor was
182
+ // `isSeedance2Provider(provider) && …`, so it never received an end anchor
183
+ // despite carrying "end-frame" in the catalog.
184
+ expect(supportsEndAnchor("minimax-h3")).toBe(true)
185
+ })
186
+
187
+ it("excludes SKUs with no closing-frame support", () => {
188
+ expect(supportsEndAnchor("gemini-omni-video")).toBe(false)
189
+ expect(supportsEndAnchor("grok-i2v")).toBe(false)
190
+ expect(supportsEndAnchor("happyhorse-ref2v")).toBe(false)
191
+ expect(supportsEndAnchor(undefined)).toBe(false)
192
+ })
193
+ })
194
+
195
+ describe("segment bounds", () => {
196
+ it("reads each provider's own duration set", () => {
197
+ expect(segmentDurationsFor("seedance-2")).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
198
+ expect(segmentDurationsFor("veo3")).toEqual([4, 6, 8])
199
+ expect(segmentDurationsFor("gemini-omni-video")).toEqual([4, 6, 8, 10])
200
+ expect(segmentDurationsFor("grok-i2v")).toEqual([6, 10])
201
+ expect(segmentDurationsFor("kling-3-omni")).toEqual([3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
202
+ expect(segmentDurationsFor("nope")).toEqual([])
203
+ expect(segmentDurationsFor(undefined)).toEqual([])
204
+ })
205
+
206
+ it("min/max are the ends of that set", () => {
207
+ expect(minSegmentSecFor("seedance-2")).toBe(4)
208
+ expect(maxSegmentSecFor("seedance-2")).toBe(15)
209
+ expect(minSegmentSecFor("veo3")).toBe(4)
210
+ expect(maxSegmentSecFor("veo3")).toBe(8)
211
+ expect(minSegmentSecFor("grok-i2v")).toBe(6)
212
+ expect(maxSegmentSecFor("grok-i2v")).toBe(10)
213
+ expect(maxSegmentSecFor("nope")).toBe(0)
214
+ })
215
+
216
+ it("separates contiguous from sparse duration sets", () => {
217
+ // Contiguous → the classic arithmetic splitter is valid.
218
+ expect(hasContiguousSegmentDurations("seedance-2")).toBe(true)
219
+ expect(hasContiguousSegmentDurations("seedance-2-fast")).toBe(true)
220
+ expect(hasContiguousSegmentDurations("seedance-2-mini")).toBe(true)
221
+ expect(hasContiguousSegmentDurations("minimax-h3")).toBe(true)
222
+ expect(hasContiguousSegmentDurations("kling-3-omni")).toBe(true)
223
+ expect(hasContiguousSegmentDurations("happyhorse-ref2v")).toBe(true)
224
+ // Sparse → needs the discrete packer.
225
+ expect(hasContiguousSegmentDurations("veo3")).toBe(false)
226
+ expect(hasContiguousSegmentDurations("veo3.1")).toBe(false)
227
+ expect(hasContiguousSegmentDurations("veo3_lite")).toBe(false)
228
+ expect(hasContiguousSegmentDurations("gemini-omni-video")).toBe(false)
229
+ expect(hasContiguousSegmentDurations("grok-i2v")).toBe(false)
230
+ expect(hasContiguousSegmentDurations("nope")).toBe(false)
231
+ })
232
+
233
+ it("derives the segment ceiling from the cap and the provider's longest segment", () => {
234
+ // The dynamic replacement for the old fixed 24.
235
+ expect(maxSegmentsFor("seedance-2", 120)).toBe(8)
236
+ expect(maxSegmentsFor("seedance-2", 600)).toBe(40)
237
+ expect(maxSegmentsFor("gemini-omni-video", 600)).toBe(60)
238
+ expect(maxSegmentsFor("grok-i2v", 600)).toBe(60)
239
+ expect(maxSegmentsFor("veo3", 600)).toBe(75)
240
+ expect(maxSegmentsFor("nope", 600)).toBe(0)
241
+ })
242
+
243
+ it("every blessed SKU has usable bounds (no silent 0)", () => {
244
+ for (const p of GVP_SUPPORTED_PROVIDERS) {
245
+ expect(minSegmentSecFor(p), p).toBeGreaterThan(0)
246
+ expect(maxSegmentSecFor(p), p).toBeGreaterThanOrEqual(minSegmentSecFor(p))
247
+ expect(maxSegmentsFor(p, 600), p).toBeGreaterThan(0)
248
+ }
249
+ })
250
+ })
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { MODEL_CATALOG } from "../model-catalog.js"
3
+ import {
4
+ IMAGE_TO_VIDEO_PROVIDERS,
5
+ TEXT_TO_VIDEO_PROVIDERS,
6
+ VIDEO_REF_LIMITS_BY_PROVIDER,
7
+ VIDEO_PROVIDERS_REQUIRING_IMAGE,
8
+ getVideoAudioCapability,
9
+ defaultVideoAspectRatio,
10
+ getMaxVideoPromptChars,
11
+ MINIMAX_H3_PROVIDERS,
12
+ isMinimaxH3Provider,
13
+ isSeedance2Provider,
14
+ SEEDANCE_2_PROVIDERS,
15
+ SEEDANCE_LIP_SYNC_PROVIDERS,
16
+ DURATION_PRICED_PROVIDERS,
17
+ VIDEO_DURATION_TIERS,
18
+ PRICING_DEFAULT_DURATION_SEC,
19
+ SEEDANCE_2_R2V_MAX_AUDIO_SEC_BY_PROVIDER,
20
+ GVP_SUPPORTED_PROVIDERS,
21
+ } from "../model-constants.js"
22
+
23
+ describe("minimax-h3 catalog", () => {
24
+ const entry = MODEL_CATALOG["minimax-h3"]
25
+
26
+ it("exists with both video modes under ONE id (no t2v twin)", () => {
27
+ expect(entry).toBeDefined()
28
+ expect(entry.kind).toBe("video")
29
+ expect([...entry.modes].sort()).toEqual(["i2v", "t2v"])
30
+ })
31
+
32
+ it("declares the two-rate resolution lever with 2K first (the UI/default tier = KIE default)", () => {
33
+ // Order matters: opts[0] is both the dropdown default and the fail-safe
34
+ // snap target, and 2K is what KIE renders when the param is omitted.
35
+ expect(entry.resolutions).toEqual(["2K", "768P"])
36
+ })
37
+
38
+ it("offers every second 4-15 and the seven-ratio aspect set incl. adaptive + 21:9", () => {
39
+ expect(entry.durations).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
40
+ expect(entry.aspectRatios).toContain("adaptive")
41
+ expect(entry.aspectRatios).toContain("21:9")
42
+ })
43
+
44
+ it("declares the multimodal feature set (end-frame + audio + reference-image)", () => {
45
+ expect(entry.features).toEqual(expect.arrayContaining(["end-frame", "audio", "reference-image"]))
46
+ })
47
+ })
48
+
49
+ describe("minimax-h3 provider wiring", () => {
50
+ it("is registered for BOTH t2v and i2v (unified node dispatches by image presence)", () => {
51
+ expect(IMAGE_TO_VIDEO_PROVIDERS).toContain("minimax-h3")
52
+ expect(TEXT_TO_VIDEO_PROVIDERS).toContain("minimax-h3")
53
+ })
54
+
55
+ it("is NOT image-required (real t2v mode exists)", () => {
56
+ expect(VIDEO_PROVIDERS_REQUIRING_IMAGE.has("minimax-h3")).toBe(false)
57
+ })
58
+
59
+ it("carries the full 9/3/3 multimodal reference caps", () => {
60
+ expect(VIDEO_REF_LIMITS_BY_PROVIDER["minimax-h3"]).toEqual({ images: 9, videos: 3, audio: 3 })
61
+ })
62
+
63
+ it("audio capability: audio_driven and always-on (H3 has NO audio toggle param)", () => {
64
+ const cap = getVideoAudioCapability("minimax-h3")
65
+ expect(cap.mode).toBe("audio_driven")
66
+ expect(cap.alwaysOn).toBe(true)
67
+ expect(cap.field).toBeUndefined()
68
+ })
69
+
70
+ it("defaults to the adaptive aspect (r2v native default; t2v coerces at the KIE layer)", () => {
71
+ expect(defaultVideoAspectRatio("minimax-h3")).toBe("adaptive")
72
+ })
73
+
74
+ it("prompt cap is the documented 7000 chars", () => {
75
+ expect(getMaxVideoPromptChars("minimax-h3")).toBe(7000)
76
+ })
77
+
78
+ it("rides the reference-audio lip-sync surface with a 15s per-clip audio cap", () => {
79
+ expect(SEEDANCE_LIP_SYNC_PROVIDERS.has("minimax-h3")).toBe(true)
80
+ expect(SEEDANCE_2_R2V_MAX_AUDIO_SEC_BY_PROVIDER["minimax-h3"]).toBe(15)
81
+ })
82
+
83
+ it("prices per second: one duration tier per allowed second, default-duration 6 for omitted requests", () => {
84
+ expect(DURATION_PRICED_PROVIDERS.has("minimax-h3")).toBe(true)
85
+ const tiers = VIDEO_DURATION_TIERS["minimax-h3"]
86
+ expect(tiers.map((t) => t.maxSeconds)).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
87
+ expect(PRICING_DEFAULT_DURATION_SEC["minimax-h3"]).toBe(6)
88
+ })
89
+ })
90
+
91
+ describe("minimax-h3 family predicate — exact membership, never prefix-matched", () => {
92
+ it("matches minimax-h3 only", () => {
93
+ expect(isMinimaxH3Provider("minimax-h3")).toBe(true)
94
+ expect([...MINIMAX_H3_PROVIDERS]).toEqual(["minimax-h3"])
95
+ })
96
+
97
+ it("does NOT match the unrelated Hailuo 02 id 'minimax' (the prefix trap)", () => {
98
+ expect(isMinimaxH3Provider("minimax")).toBe(false)
99
+ expect(isMinimaxH3Provider(undefined)).toBe(false)
100
+ expect(isMinimaxH3Provider("")).toBe(false)
101
+ })
102
+
103
+ it("stays OUT of the Seedance 2 family set (which gates Seedance-specific KIE params + -ref pricing)", () => {
104
+ expect(isSeedance2Provider("minimax-h3")).toBe(false)
105
+ expect(SEEDANCE_2_PROVIDERS.has("minimax-h3")).toBe(false)
106
+ })
107
+
108
+ it("IS a blessed GVP SKU (2026-08-02; EVP keeps its Seedance-only subset) while staying OUT of the Seedance family", () => {
109
+ // The blessing landed: minimax-h3 is offered by the pro engine (full
110
+ // transport analog — shared Seedance-2 input resolver, 9/3/3 ref caps,
111
+ // per-second 4-15s durations). It still must NOT join SEEDANCE_2_PROVIDERS
112
+ // (the family set gates Seedance-specific KIE params + -ref pricing) —
113
+ // the test above pins that separately.
114
+ expect([...GVP_SUPPORTED_PROVIDERS]).toContain("minimax-h3")
115
+ })
116
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildVideoCreditModelIdentifier } from "../credit-identifiers.js"
3
+ import { normalizeMinimaxH3Resolution } from "../model-constants.js"
4
+
5
+ /**
6
+ * minimax-h3 prices per second at TWO resolution rates (2K default / 768P,
7
+ * 2026-08-03) and has NO -ref identifier dimension. Bare duration composites
8
+ * are the 2K rate — byte-identical to the pre-lever rows, so existing
9
+ * workflows/overrides keep their ids — and only a verified 768P selection
10
+ * appends ":768p". Reference-video input seconds and >5-image surcharges are
11
+ * reserved by the minimax-h3-credits compute hook instead of the identifier.
12
+ */
13
+ describe("minimax-h3 credit identifier", () => {
14
+ it("collapses every non-768P resolution onto the bare 2K composite (what KIE renders for those values)", () => {
15
+ for (const res of [undefined, "480p", "720p", "1080p", "2k", "2K", "4k", "garbage"]) {
16
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 8, undefined, "image-to-video", undefined, res)).toBe("minimax-h3:8s")
17
+ }
18
+ })
19
+
20
+ it("appends :768p only for a verified 768P selection (case-insensitive)", () => {
21
+ for (const res of ["768P", "768p", " 768P "]) {
22
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 8, undefined, "image-to-video", undefined, res)).toBe("minimax-h3:8s:768p")
23
+ }
24
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 4, undefined, "text-to-video", undefined, "768P")).toBe("minimax-h3:4s:768p")
25
+ expect(buildVideoCreditModelIdentifier("minimax-h3", undefined, undefined, undefined, undefined, "768P")).toBe("minimax-h3:6s:768p")
26
+ })
27
+
28
+ it("normalizeMinimaxH3Resolution is the single collapse rule (768p → 768P, everything else → 2K)", () => {
29
+ expect(normalizeMinimaxH3Resolution("768p")).toBe("768P")
30
+ expect(normalizeMinimaxH3Resolution("768P")).toBe("768P")
31
+ expect(normalizeMinimaxH3Resolution(undefined)).toBe("2K")
32
+ expect(normalizeMinimaxH3Resolution("720p")).toBe("2K")
33
+ expect(normalizeMinimaxH3Resolution("garbage")).toBe("2K")
34
+ })
35
+
36
+ it("ignores hasVideoRef (no -ref dimension — the compute hook bills input seconds)", () => {
37
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 8, undefined, "image-to-video", undefined, undefined, true)).toBe("minimax-h3:8s")
38
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 8, undefined, "text-to-video", undefined, undefined, true)).toBe("minimax-h3:8s")
39
+ })
40
+
41
+ it("maps every on-menu second 1:1 to its own tier", () => {
42
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 4)).toBe("minimax-h3:4s")
43
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 5)).toBe("minimax-h3:5s")
44
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 13)).toBe("minimax-h3:13s")
45
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 15)).toBe("minimax-h3:15s")
46
+ })
47
+
48
+ it("an OMITTED duration prices the model's own 6s default (not the global 5s) — a duration-less request renders 6s", () => {
49
+ expect(buildVideoCreditModelIdentifier("minimax-h3", undefined)).toBe("minimax-h3:6s")
50
+ expect(buildVideoCreditModelIdentifier("minimax-h3", "not-a-number")).toBe("minimax-h3:6s")
51
+ })
52
+
53
+ it("clamps off-menu durations into the tier ladder", () => {
54
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 3)).toBe("minimax-h3:4s")
55
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 20)).toBe("minimax-h3:15s")
56
+ })
57
+
58
+ it("sound has no effect (audio is always on and priced in — no :audio addon)", () => {
59
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 8, true)).toBe("minimax-h3:8s")
60
+ expect(buildVideoCreditModelIdentifier("minimax-h3", 8, false)).toBe("minimax-h3:8s")
61
+ })
62
+ })
@@ -23,11 +23,32 @@ describe("reference-roles registry", () => {
23
23
 
24
24
  it("defaultRoleForSource mirrors DEFAULT_LABEL_BY_SOURCE", () => {
25
25
  expect(defaultRoleForSource("wired-character")).toBe("person")
26
- expect(defaultRoleForSource("wired-location")).toBe("background")
26
+ expect(defaultRoleForSource("wired-location")).toBe("location")
27
27
  expect(defaultRoleForSource("wired-object")).toBe("object")
28
28
  expect(defaultRoleForSource("wired-creature")).toBe("creature")
29
29
  })
30
30
 
31
+ // A LOCATION IS A PLACE, NOT A BACKDROP (2026-08-05). The default was
32
+ // "background", which rendered "the background from reference image B" —
33
+ // read by the image models as an instruction to PASTE a backdrop. Measured on
34
+ // gpt-image-2 with a character + location pair: the old default produced a
35
+ // cut-out composite in 4/4 draws (an indoor-lit subject over a stock beach,
36
+ // no cast shadow, not performing the asked-for action), and renaming the
37
+ // default ALONE — nothing else in the prompt touched — put the subject inside
38
+ // the scene, walking, with ground contact and a cast shadow, in 2/4.
39
+ // "background" stays a curated pick for the cases that genuinely want only a
40
+ // backdrop; it is just no longer what every location silently gets.
41
+ it("defaults a wired location to a PLACE phrase, never a backdrop paste", () => {
42
+ const phrase = roleToPhrase(defaultRoleForSource("wired-location"), "reference image B")
43
+ expect(phrase).toBe("the location from reference image B")
44
+ expect(phrase).not.toContain("background")
45
+ })
46
+
47
+ it("keeps 'background' available as an explicit location pick", () => {
48
+ expect(REFERENCE_ROLE_PRESETS["wired-location"]).toContain("background")
49
+ expect(roleToPhrase("background", "reference image B")).toBe("the background from reference image B")
50
+ })
51
+
31
52
  it("renders noun roles as 'the {role} from {binding}'", () => {
32
53
  expect(roleToPhrase("person", "reference image A")).toBe("the person from reference image A")
33
54
  expect(roleToPhrase("face", "@image_3")).toBe("the face from @image_3")