@nodaro/shared 2.17.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +260 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +111 -6
- package/dist/index.d.ts +111 -6
- package/dist/index.js +251 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/apply-video-negative-prompt.test.ts +38 -1
- package/src/__tests__/credit-identifiers.test.ts +47 -0
- package/src/__tests__/gvp-supported-providers.test.ts +22 -1
- package/src/__tests__/prompt-length-limits.test.ts +4 -0
- package/src/__tests__/video-audio-capability.test.ts +43 -0
- package/src/__tests__/video-mode-for-inputs.test.ts +1 -1
- package/src/__tests__/wan-3-catalog.test.ts +243 -0
- package/src/credit-identifiers.ts +42 -9
- package/src/i18n/held-prop.ar.ts +2 -0
- package/src/i18n/held-prop.de.ts +3 -0
- package/src/i18n/held-prop.es.ts +3 -0
- package/src/i18n/held-prop.fr.ts +2 -0
- package/src/i18n/held-prop.he.ts +3 -0
- package/src/i18n/held-prop.hi.ts +2 -0
- package/src/i18n/held-prop.ja.ts +3 -0
- package/src/i18n/held-prop.ko.ts +3 -0
- package/src/i18n/held-prop.pt-BR.ts +3 -0
- package/src/i18n/held-prop.ru.ts +2 -0
- package/src/i18n/held-prop.zh-CN.ts +3 -0
- package/src/i18n/person.ar.ts +3 -0
- package/src/i18n/person.de.ts +4 -0
- package/src/i18n/person.es.ts +4 -0
- package/src/i18n/person.fr.ts +3 -0
- package/src/i18n/person.he.ts +4 -0
- package/src/i18n/person.hi.ts +3 -0
- package/src/i18n/person.ja.ts +4 -0
- package/src/i18n/person.ko.ts +4 -0
- package/src/i18n/person.pt-BR.ts +4 -0
- package/src/i18n/person.ru.ts +3 -0
- package/src/i18n/person.zh-CN.ts +4 -0
- package/src/i18n/setting.ar.ts +2 -0
- package/src/i18n/setting.de.ts +3 -0
- package/src/i18n/setting.es.ts +3 -0
- package/src/i18n/setting.fr.ts +2 -0
- package/src/i18n/setting.he.ts +3 -0
- package/src/i18n/setting.hi.ts +2 -0
- package/src/i18n/setting.ja.ts +3 -0
- package/src/i18n/setting.ko.ts +3 -0
- package/src/i18n/setting.pt-BR.ts +3 -0
- package/src/i18n/setting.ru.ts +2 -0
- package/src/i18n/setting.zh-CN.ts +3 -0
- package/src/i18n/style.ar.ts +2 -0
- package/src/i18n/style.de.ts +3 -0
- package/src/i18n/style.es.ts +3 -0
- package/src/i18n/style.fr.ts +2 -0
- package/src/i18n/style.he.ts +3 -0
- package/src/i18n/style.hi.ts +2 -0
- package/src/i18n/style.ja.ts +3 -0
- package/src/i18n/style.ko.ts +3 -0
- package/src/i18n/style.pt-BR.ts +3 -0
- package/src/i18n/style.ru.ts +2 -0
- package/src/i18n/style.zh-CN.ts +3 -0
- package/src/i18n/styling.ar.ts +4 -0
- package/src/i18n/styling.de.ts +5 -0
- package/src/i18n/styling.es.ts +5 -0
- package/src/i18n/styling.fr.ts +4 -0
- package/src/i18n/styling.he.ts +5 -0
- package/src/i18n/styling.hi.ts +4 -0
- package/src/i18n/styling.ja.ts +5 -0
- package/src/i18n/styling.ko.ts +5 -0
- package/src/i18n/styling.pt-BR.ts +5 -0
- package/src/i18n/styling.ru.ts +4 -0
- package/src/i18n/styling.zh-CN.ts +5 -0
- package/src/index.ts +10 -0
- package/src/model-catalog.ts +101 -0
- package/src/model-constants.ts +217 -8
- package/src/node-default-mappings.ts +5 -0
- package/src/video-ui-defaults.ts +66 -0
package/package.json
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
applyVideoNegativePrompt,
|
|
4
|
+
videoNegativeSuffix,
|
|
5
|
+
NATIVE_NEGATIVE_VIDEO_PROVIDERS,
|
|
6
|
+
} from "../model-constants.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A composed prompt that ends with the `[style]` section — what
|
|
10
|
+
* `@nodaro/prompts` emits for any run that picks a look dimension. The section
|
|
11
|
+
* has no terminator, so a `"\n"`-joined `Avoid:` reads as one more look clause
|
|
12
|
+
* under its header.
|
|
13
|
+
*/
|
|
14
|
+
const SECTIONED = "a knight rides\n\n[style]:\nanime style\nwide shot"
|
|
15
|
+
|
|
16
|
+
describe("videoNegativeSuffix", () => {
|
|
17
|
+
it("joins on one newline for an ordinary prompt", () => {
|
|
18
|
+
expect(videoNegativeSuffix("blurry", "a knight rides")).toBe("\nAvoid: blurry")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("closes an open `[style]` section with a blank line", () => {
|
|
22
|
+
expect(videoNegativeSuffix("blurry", SECTIONED)).toBe("\n\nAvoid: blurry")
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("joins on one newline once something else closed the section", () => {
|
|
26
|
+
expect(videoNegativeSuffix("blurry", `${SECTIONED}\n\nthe person from @image_1`))
|
|
27
|
+
.toBe("\nAvoid: blurry")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("is the WIDEST form with no base — what a caller reserving room must budget", () => {
|
|
31
|
+
// `effectiveVideoPromptCeiling` reserves before the prompt exists.
|
|
32
|
+
expect(videoNegativeSuffix("blurry")).toBe("\n\nAvoid: blurry")
|
|
33
|
+
})
|
|
34
|
+
})
|
|
3
35
|
|
|
4
36
|
describe("applyVideoNegativePrompt", () => {
|
|
5
37
|
it("returns prompt unchanged + nativeNegativePrompt when provider is native", () => {
|
|
@@ -14,6 +46,11 @@ describe("applyVideoNegativePrompt", () => {
|
|
|
14
46
|
expect(result.nativeNegativePrompt).toBeUndefined()
|
|
15
47
|
})
|
|
16
48
|
|
|
49
|
+
it("separates 'Avoid: …' from an unterminated `[style]` section", () => {
|
|
50
|
+
const result = applyVideoNegativePrompt(SECTIONED, "blurry", "wan-animate-move")
|
|
51
|
+
expect(result.prompt).toBe(`${SECTIONED}\n\nAvoid: blurry`)
|
|
52
|
+
})
|
|
53
|
+
|
|
17
54
|
it("returns 'Avoid: …' alone when prompt is empty and provider is non-native", () => {
|
|
18
55
|
const result = applyVideoNegativePrompt("", "blurry", "veo3")
|
|
19
56
|
expect(result.prompt).toBe("Avoid: blurry")
|
|
@@ -823,3 +823,50 @@ describe("gemini-omni-video", () => {
|
|
|
823
823
|
expect(id({ duration: 6, resolution: "720p", nodeType: "text-to-video" })).toBe("gemini-omni-video:6")
|
|
824
824
|
})
|
|
825
825
|
})
|
|
826
|
+
|
|
827
|
+
// ---------------------------------------------------------------------------
|
|
828
|
+
// gemini-omni-flash credit identifier — the family branch reaches the sibling
|
|
829
|
+
// ---------------------------------------------------------------------------
|
|
830
|
+
// The Omni branch used to compare against the literal "gemini-omni-video" and
|
|
831
|
+
// hardcode that prefix in every returned string. It now asks
|
|
832
|
+
// isGeminiOmniProvider() and TEMPLATES the prefix, so this block is the proof
|
|
833
|
+
// that the refactor actually reaches the second SKU (the block above proves it
|
|
834
|
+
// stayed behaviour-neutral for the first). A flash run that fell through to the
|
|
835
|
+
// DURATION_PRICED gate would return the BARE id and reserve 270 credits for a
|
|
836
|
+
// 4K 10s render.
|
|
837
|
+
describe("gemini-omni-flash", () => {
|
|
838
|
+
const id = (opts: { duration?: number | string; resolution?: string; hasVideoRef?: boolean; nodeType?: "image-to-video" | "text-to-video" }) =>
|
|
839
|
+
buildVideoCreditModelIdentifier("gemini-omni-flash", opts.duration, undefined, opts.nodeType ?? "image-to-video", undefined, opts.resolution, opts.hasVideoRef)
|
|
840
|
+
|
|
841
|
+
it("prices 720p/1080p by duration tier", () => {
|
|
842
|
+
expect(id({ duration: 4, resolution: "720p" })).toBe("gemini-omni-flash:4")
|
|
843
|
+
expect(id({ duration: 6, resolution: "1080p" })).toBe("gemini-omni-flash:6")
|
|
844
|
+
expect(id({ duration: 8, resolution: "720p" })).toBe("gemini-omni-flash:8")
|
|
845
|
+
expect(id({ duration: 10, resolution: "1080p" })).toBe("gemini-omni-flash:10")
|
|
846
|
+
})
|
|
847
|
+
it("prices 4k by duration tier", () => {
|
|
848
|
+
expect(id({ duration: 4, resolution: "4k" })).toBe("gemini-omni-flash:4k:4")
|
|
849
|
+
expect(id({ duration: 10, resolution: "4k" })).toBe("gemini-omni-flash:4k:10")
|
|
850
|
+
})
|
|
851
|
+
it("prices V2V flat, ignoring duration", () => {
|
|
852
|
+
expect(id({ duration: 8, resolution: "1080p", hasVideoRef: true })).toBe("gemini-omni-flash:vref")
|
|
853
|
+
expect(id({ duration: 8, resolution: "4k", hasVideoRef: true })).toBe("gemini-omni-flash:4k:vref")
|
|
854
|
+
})
|
|
855
|
+
it("snaps off-tier durations to nearest allowed tier (never :5)", () => {
|
|
856
|
+
expect(id({ duration: 5, resolution: "720p" })).toBe("gemini-omni-flash:4")
|
|
857
|
+
expect(id({ duration: 12, resolution: "720p" })).toBe("gemini-omni-flash:10")
|
|
858
|
+
})
|
|
859
|
+
it("defaults to tier 8 when duration is undefined", () => {
|
|
860
|
+
expect(id({ resolution: "720p" })).toBe("gemini-omni-flash:8")
|
|
861
|
+
})
|
|
862
|
+
it("works on the text-to-video path too", () => {
|
|
863
|
+
expect(id({ duration: 6, resolution: "720p", nodeType: "text-to-video" })).toBe("gemini-omni-flash:6")
|
|
864
|
+
})
|
|
865
|
+
it("never falls through to the bare id (the pre-refactor failure mode)", () => {
|
|
866
|
+
for (const d of [undefined, 4, 6, 8, 10, 5, 12]) {
|
|
867
|
+
for (const r of [undefined, "720p", "1080p", "4k"]) {
|
|
868
|
+
expect(id({ duration: d, resolution: r })).not.toBe("gemini-omni-flash")
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
})
|
|
872
|
+
})
|
|
@@ -51,7 +51,10 @@ describe("GVP_SUPPORTED_PROVIDERS", () => {
|
|
|
51
51
|
"veo3.1",
|
|
52
52
|
"veo3_lite",
|
|
53
53
|
"gemini-omni-video",
|
|
54
|
+
"gemini-omni-flash",
|
|
54
55
|
"grok-i2v",
|
|
56
|
+
"wan-3",
|
|
57
|
+
"wan-3-prime",
|
|
55
58
|
"happyhorse-ref2v",
|
|
56
59
|
])
|
|
57
60
|
})
|
|
@@ -154,8 +157,20 @@ describe("GVP_EXTEND_PROVIDERS", () => {
|
|
|
154
157
|
expect(supportsExtendRender("gemini-omni-video")).toBe(false)
|
|
155
158
|
})
|
|
156
159
|
|
|
160
|
+
it("excludes wan-3 — reference videos are accepted but the extend chain is unwired", () => {
|
|
161
|
+
// Wan 3.0 takes reference_video_urls, but (a) there is no wan-3 r2v
|
|
162
|
+
// forwarding path yet and (b) KIE caps input-video seconds + output
|
|
163
|
+
// duration at 30, which the segment bounds cannot express. So the catalog
|
|
164
|
+
// deliberately withholds the `video-reference` feature: blessed for
|
|
165
|
+
// keyframes, not for extend. Same precedent as gemini-omni-video.
|
|
166
|
+
for (const p of ["wan-3", "wan-3-prime"]) {
|
|
167
|
+
expect(isGvpSupportedProvider(p)).toBe(true)
|
|
168
|
+
expect(supportsExtendRender(p)).toBe(false)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
157
172
|
it("keyframes-only SKUs reject extend", () => {
|
|
158
|
-
for (const p of ["veo3", "veo3.1", "veo3_lite", "gemini-omni-video", "grok-i2v", "happyhorse-ref2v"]) {
|
|
173
|
+
for (const p of ["veo3", "veo3.1", "veo3_lite", "gemini-omni-video", "gemini-omni-flash", "grok-i2v", "happyhorse-ref2v", "wan-3", "wan-3-prime"]) {
|
|
159
174
|
expect(isGvpSupportedProvider(p)).toBe(true)
|
|
160
175
|
expect(supportsExtendRender(p)).toBe(false)
|
|
161
176
|
}
|
|
@@ -174,6 +189,8 @@ describe("GVP_END_FRAME_PROVIDERS", () => {
|
|
|
174
189
|
"veo3",
|
|
175
190
|
"veo3.1",
|
|
176
191
|
"veo3_lite",
|
|
192
|
+
"wan-3",
|
|
193
|
+
"wan-3-prime",
|
|
177
194
|
])
|
|
178
195
|
})
|
|
179
196
|
|
|
@@ -186,6 +203,10 @@ describe("GVP_END_FRAME_PROVIDERS", () => {
|
|
|
186
203
|
|
|
187
204
|
it("excludes SKUs with no closing-frame support", () => {
|
|
188
205
|
expect(supportsEndAnchor("gemini-omni-video")).toBe(false)
|
|
206
|
+
// Gemini Omni Flash mirrors its sibling exactly — the KIE schema exposes
|
|
207
|
+
// first/last frame but the path is unwired for BOTH Omni SKUs, so neither
|
|
208
|
+
// declares the catalog `end-frame` feature.
|
|
209
|
+
expect(supportsEndAnchor("gemini-omni-flash")).toBe(false)
|
|
189
210
|
expect(supportsEndAnchor("grok-i2v")).toBe(false)
|
|
190
211
|
expect(supportsEndAnchor("happyhorse-ref2v")).toBe(false)
|
|
191
212
|
expect(supportsEndAnchor(undefined)).toBe(false)
|
|
@@ -66,6 +66,10 @@ describe("per-model prompt length limits", () => {
|
|
|
66
66
|
it("returns verified higher caps", () => {
|
|
67
67
|
expect(getMaxVideoPromptChars("seedance-2")).toBe(20000)
|
|
68
68
|
expect(getMaxVideoPromptChars("gemini-omni-video")).toBe(20000)
|
|
69
|
+
expect(getMaxVideoPromptChars("gemini-omni-flash")).toBe(20000)
|
|
70
|
+
// Wan 3.0 family — 20000 chars, truncated by the provider beyond that.
|
|
71
|
+
expect(getMaxVideoPromptChars("wan-3")).toBe(20000)
|
|
72
|
+
expect(getMaxVideoPromptChars("wan-3-prime")).toBe(20000)
|
|
69
73
|
expect(getMaxVideoPromptChars("bytedance-pro")).toBe(10000)
|
|
70
74
|
})
|
|
71
75
|
it("falls back to VIDEO_PROMPT_MAX for UNVERIFIED + unknown providers", () => {
|
|
@@ -63,6 +63,21 @@ describe("getVideoAudioCapability", () => {
|
|
|
63
63
|
expect(cap.field).toBe("generateAudio")
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
+
it("returns ambient with the `audio` field for the Wan 3.0 family", () => {
|
|
67
|
+
// Wan 3.0's lever is literally `input.audio` — neither `sound` nor
|
|
68
|
+
// `generate_audio`. Classified "ambient", NOT audio_driven: the KIE schema
|
|
69
|
+
// documents no dialogue guarantee, and reference audio is a generic
|
|
70
|
+
// conditioning array rather than a verified lip-sync transport.
|
|
71
|
+
for (const m of ["wan-3", "wan-3-prime"]) {
|
|
72
|
+
const cap = getVideoAudioCapability(m)
|
|
73
|
+
expect(cap.mode, m).toBe("ambient")
|
|
74
|
+
expect(cap.field, m).toBe("audio")
|
|
75
|
+
expect(cap.defaultOn, m).toBe(true)
|
|
76
|
+
// Audio is priced into the uniform per-second rate — no `:audio` composite.
|
|
77
|
+
expect(cap.affectsCost, m).toBeUndefined()
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
66
81
|
it("defaults to none for silent / unknown / undefined models", () => {
|
|
67
82
|
for (const m of [
|
|
68
83
|
"minimax",
|
|
@@ -70,6 +85,9 @@ describe("getVideoAudioCapability", () => {
|
|
|
70
85
|
"wan-i2v",
|
|
71
86
|
"grok-i2v",
|
|
72
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",
|
|
73
91
|
"runway",
|
|
74
92
|
"pika",
|
|
75
93
|
"totally-unknown-model",
|
|
@@ -192,6 +210,31 @@ describe("applyVideoAudioToggle — neutral audio intent → per-model KIE field
|
|
|
192
210
|
expect(v1.generate_audio).toBe(true)
|
|
193
211
|
})
|
|
194
212
|
|
|
213
|
+
it("maps the neutral toggle onto Wan 3.0's `audio` field", () => {
|
|
214
|
+
// The dispatcher used to be a two-way if/else whose `else` wrote
|
|
215
|
+
// `input.sound` — a field the Wan 3.0 contract does not have, so the
|
|
216
|
+
// toggle would have been silently dropped.
|
|
217
|
+
const on: Record<string, unknown> = {}
|
|
218
|
+
applyVideoAudioToggle(on, "wan-3", { sound: true })
|
|
219
|
+
expect(on.audio).toBe(true)
|
|
220
|
+
expect(on.sound).toBeUndefined()
|
|
221
|
+
expect(on.generate_audio).toBeUndefined()
|
|
222
|
+
|
|
223
|
+
const off: Record<string, unknown> = {}
|
|
224
|
+
applyVideoAudioToggle(off, "wan-3-prime", { sound: false })
|
|
225
|
+
expect(off.audio).toBe(false)
|
|
226
|
+
|
|
227
|
+
// Not cost-affecting, so the legacy alias is honoured too.
|
|
228
|
+
const alias: Record<string, unknown> = {}
|
|
229
|
+
applyVideoAudioToggle(alias, "wan-3", { generateAudio: true })
|
|
230
|
+
expect(alias.audio).toBe(true)
|
|
231
|
+
|
|
232
|
+
// No intent → the model's own default (KIE `audio: true`) is left alone.
|
|
233
|
+
const neutral: Record<string, unknown> = {}
|
|
234
|
+
applyVideoAudioToggle(neutral, "wan-3", {})
|
|
235
|
+
expect(neutral).toEqual({})
|
|
236
|
+
})
|
|
237
|
+
|
|
195
238
|
it("accepts `generateAudio` as a legacy alias on FREE models (Seedance)", () => {
|
|
196
239
|
const input: Record<string, unknown> = {}
|
|
197
240
|
applyVideoAudioToggle(input, "seedance", { generateAudio: true })
|
|
@@ -43,7 +43,7 @@ describe("resolveVideoModeForInputs", () => {
|
|
|
43
43
|
// (generate-video.md mode table). kling-3-omni is i2v-only and keeps its
|
|
44
44
|
// documented orchestrator behavior (image_required on the t2v path) —
|
|
45
45
|
// whether refs alone should satisfy it is a separate question from #861.
|
|
46
|
-
for (const id of ["seedance-2", "gemini-omni-video", "veo3.1", "kling-3-omni"]) {
|
|
46
|
+
for (const id of ["seedance-2", "gemini-omni-video", "gemini-omni-flash", "veo3.1", "kling-3-omni", "wan-3", "wan-3-prime"]) {
|
|
47
47
|
expect(resolveVideoModeForInputs(id, { hasStartFrame: false, hasImageRefs: true })).toBe(T2V)
|
|
48
48
|
}
|
|
49
49
|
})
|
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
WAN_3_PROVIDERS,
|
|
12
|
+
isWan3Provider,
|
|
13
|
+
WAN_3_DEFAULT_RESOLUTION,
|
|
14
|
+
normalizeWan3Resolution,
|
|
15
|
+
isSeedance2Provider,
|
|
16
|
+
isMinimaxH3Provider,
|
|
17
|
+
SEEDANCE_2_PROVIDERS,
|
|
18
|
+
DURATION_PRICED_PROVIDERS,
|
|
19
|
+
VIDEO_DURATION_TIERS,
|
|
20
|
+
RESOLUTION_DURATION_PRICING,
|
|
21
|
+
PRICING_DEFAULT_RESOLUTION,
|
|
22
|
+
NATIVE_ADAPTIVE_ASPECT,
|
|
23
|
+
SEEDANCE_2_R2V_MAX_AUDIO_SEC_BY_PROVIDER,
|
|
24
|
+
GVP_SUPPORTED_PROVIDERS,
|
|
25
|
+
GVP_EXTEND_PROVIDERS,
|
|
26
|
+
} from "../model-constants.js"
|
|
27
|
+
import { buildVideoCreditModelIdentifier } from "../credit-identifiers.js"
|
|
28
|
+
|
|
29
|
+
const WAN_3_IDS = ["wan-3", "wan-3-prime"] as const
|
|
30
|
+
const DURATIONS_2_TO_30 = Array.from({ length: 29 }, (_, i) => i + 2)
|
|
31
|
+
|
|
32
|
+
describe("wan-3 / wan-3-prime catalog", () => {
|
|
33
|
+
for (const id of WAN_3_IDS) {
|
|
34
|
+
describe(id, () => {
|
|
35
|
+
const entry = MODEL_CATALOG[id]
|
|
36
|
+
|
|
37
|
+
it("exists with both video modes under ONE id (no t2v twin, no alias)", () => {
|
|
38
|
+
expect(entry).toBeDefined()
|
|
39
|
+
expect(entry.kind).toBe("video")
|
|
40
|
+
expect([...entry.modes].sort()).toEqual(["i2v", "t2v"])
|
|
41
|
+
expect(entry.series).toBe("Wan")
|
|
42
|
+
expect(entry.family).toBe("Alibaba")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("lists resolutions ASCENDING — the default is declared, not smuggled in at index 0", () => {
|
|
46
|
+
// Every video entry in the catalog is ascending, and three unrelated
|
|
47
|
+
// consumers read index 0 (the frontend fail-safe snap, payload-builder's
|
|
48
|
+
// resolution fill, the GVP display order). The 720p billing/render
|
|
49
|
+
// default lives in PRICING_DEFAULT_RESOLUTION instead.
|
|
50
|
+
expect(entry.resolutions).toEqual(["480p", "720p", "1080p"])
|
|
51
|
+
expect(PRICING_DEFAULT_RESOLUTION[id]).toBe("720p")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("offers every integer second 2-30 (`-1` model-chosen duration is NOT exposed)", () => {
|
|
55
|
+
expect(entry.durations).toEqual(DURATIONS_2_TO_30)
|
|
56
|
+
expect(entry.durations).not.toContain(-1)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("uses the Wan 3.0 six-ratio set: adaptive first, NO 21:9", () => {
|
|
60
|
+
expect(entry.aspectRatios).toEqual(["adaptive", "16:9", "4:3", "1:1", "3:4", "9:16"])
|
|
61
|
+
// Reusing VIDEO_RATIOS_SEEDANCE_2 would have offered 21:9, which the
|
|
62
|
+
// Wan 3.0 enum rejects at request time.
|
|
63
|
+
expect(entry.aspectRatios).not.toContain("21:9")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("declares end-frame + audio + reference-image, and deliberately NOT video-reference", () => {
|
|
67
|
+
expect(entry.features).toEqual(expect.arrayContaining(["end-frame", "audio", "reference-image"]))
|
|
68
|
+
// `video-reference` would derive wan into GVP_EXTEND_PROVIDERS, but the
|
|
69
|
+
// r2v forwarding path is unwired and KIE caps input-video + output at
|
|
70
|
+
// 30s — a bound the segment splitter cannot express.
|
|
71
|
+
expect(entry.features).not.toContain("video-reference")
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("wan-3 provider wiring", () => {
|
|
78
|
+
for (const id of WAN_3_IDS) {
|
|
79
|
+
it(`${id} is registered for BOTH t2v and i2v and is NOT image-required`, () => {
|
|
80
|
+
expect(IMAGE_TO_VIDEO_PROVIDERS).toContain(id)
|
|
81
|
+
expect(TEXT_TO_VIDEO_PROVIDERS).toContain(id)
|
|
82
|
+
expect(VIDEO_PROVIDERS_REQUIRING_IMAGE.has(id)).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it(`${id} carries the 10/5/5 multimodal reference caps`, () => {
|
|
86
|
+
expect(VIDEO_REF_LIMITS_BY_PROVIDER[id]).toEqual({ images: 10, videos: 5, audio: 5 })
|
|
87
|
+
expect(SEEDANCE_2_R2V_MAX_AUDIO_SEC_BY_PROVIDER[id]).toBe(15)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it(`${id} audio capability: ambient behind the model's own \`audio\` boolean, default on`, () => {
|
|
91
|
+
const cap = getVideoAudioCapability(id)
|
|
92
|
+
expect(cap.mode).toBe("ambient")
|
|
93
|
+
expect(cap.field).toBe("audio")
|
|
94
|
+
expect(cap.defaultOn).toBe(true)
|
|
95
|
+
expect(cap.affectsCost).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it(`${id} defaults to the adaptive aspect (the KIE default, first in its enum)`, () => {
|
|
99
|
+
expect(defaultVideoAspectRatio(id)).toBe("adaptive")
|
|
100
|
+
expect(NATIVE_ADAPTIVE_ASPECT[id]).toBe("adaptive")
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it(`${id} prompt cap is the documented 20000 chars`, () => {
|
|
104
|
+
expect(getMaxVideoPromptChars(id)).toBe(20000)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it(`${id} prices per second: one duration tier per allowed second 2-30`, () => {
|
|
108
|
+
expect(DURATION_PRICED_PROVIDERS.has(id)).toBe(true)
|
|
109
|
+
const tiers = VIDEO_DURATION_TIERS[id]
|
|
110
|
+
expect(tiers.map((t) => t.maxSeconds)).toEqual(DURATIONS_2_TO_30)
|
|
111
|
+
expect(RESOLUTION_DURATION_PRICING[id]).toEqual(["480p", "720p", "1080p"])
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe("wan-3 family predicate — exact membership, never prefix-matched", () => {
|
|
117
|
+
it("matches exactly the two Wan 3.0 SKUs", () => {
|
|
118
|
+
expect([...WAN_3_PROVIDERS].sort()).toEqual(["wan-3", "wan-3-prime"])
|
|
119
|
+
expect(isWan3Provider("wan-3")).toBe(true)
|
|
120
|
+
expect(isWan3Provider("wan-3-prime")).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("does NOT match any Wan 2.x id (the prefix trap)", () => {
|
|
124
|
+
for (const other of ["wan", "wan-i2v", "wan-turbo", "wan-flash", "wan-videoedit", "wan-2.7-i2v", "wan-2.7-t2v"]) {
|
|
125
|
+
expect(isWan3Provider(other), other).toBe(false)
|
|
126
|
+
}
|
|
127
|
+
expect(isWan3Provider(undefined)).toBe(false)
|
|
128
|
+
expect(isWan3Provider("")).toBe(false)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("stays OUT of the Seedance 2 and MiniMax H3 family sets", () => {
|
|
132
|
+
for (const id of WAN_3_IDS) {
|
|
133
|
+
expect(isSeedance2Provider(id), id).toBe(false)
|
|
134
|
+
expect(SEEDANCE_2_PROVIDERS.has(id), id).toBe(false)
|
|
135
|
+
expect(isMinimaxH3Provider(id), id).toBe(false)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("IS a blessed GVP SKU (derived) but NOT extend-eligible", () => {
|
|
140
|
+
for (const id of WAN_3_IDS) {
|
|
141
|
+
expect([...GVP_SUPPORTED_PROVIDERS], id).toContain(id)
|
|
142
|
+
expect([...GVP_EXTEND_PROVIDERS], id).not.toContain(id)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe("normalizeWan3Resolution — the ONE producer of the uppercase wire form", () => {
|
|
148
|
+
it("uppercases a supported tier, case-insensitively", () => {
|
|
149
|
+
expect(normalizeWan3Resolution("480p")).toBe("480P")
|
|
150
|
+
expect(normalizeWan3Resolution("480P")).toBe("480P")
|
|
151
|
+
expect(normalizeWan3Resolution("720p")).toBe("720P")
|
|
152
|
+
expect(normalizeWan3Resolution("1080P")).toBe("1080P")
|
|
153
|
+
expect(normalizeWan3Resolution(" 1080p ")).toBe("1080P")
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("collapses undefined / garbage / an off-menu tier to the 720P platform default", () => {
|
|
157
|
+
expect(WAN_3_DEFAULT_RESOLUTION).toBe("720P")
|
|
158
|
+
// 720P, not KIE's own 1080P default: the bare credit identifier prices the
|
|
159
|
+
// 720p tier, so billing can never undercut the render.
|
|
160
|
+
expect(normalizeWan3Resolution(undefined)).toBe("720P")
|
|
161
|
+
expect(normalizeWan3Resolution("")).toBe("720P")
|
|
162
|
+
expect(normalizeWan3Resolution("4k")).toBe("720P")
|
|
163
|
+
expect(normalizeWan3Resolution("2K")).toBe("720P")
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe("wan-3 credit identifiers — totality over the whole (duration × resolution) space", () => {
|
|
168
|
+
const build = (provider: string, duration?: number, resolution?: string) =>
|
|
169
|
+
buildVideoCreditModelIdentifier(provider, duration, undefined, "image-to-video", undefined, resolution, undefined)
|
|
170
|
+
|
|
171
|
+
for (const id of WAN_3_IDS) {
|
|
172
|
+
it(`${id} emits exactly one composite per (duration, resolution) pair — 87 distinct ids`, () => {
|
|
173
|
+
const emitted = new Set<string>()
|
|
174
|
+
for (const d of DURATIONS_2_TO_30) {
|
|
175
|
+
for (const res of ["480p", "720p", "1080p"]) {
|
|
176
|
+
const identifier = build(id, d, res)
|
|
177
|
+
expect(identifier, `${id} ${d}s ${res}`).toBe(`${id}:${d}s:${res}`)
|
|
178
|
+
emitted.add(identifier)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
expect(emitted.size).toBe(87)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it(`${id} prices an OMITTED resolution at the declared 720p default, not the cheapest tier`, () => {
|
|
185
|
+
// The tier list is ascending, so a bare resTiers[0] fallback would have
|
|
186
|
+
// reserved 480p against a 720p render — and commit_credits (refund-only)
|
|
187
|
+
// can never collect the shortfall.
|
|
188
|
+
expect(build(id, 5)).toBe(`${id}:5s:720p`)
|
|
189
|
+
expect(build(id, 8)).toBe(`${id}:8s:720p`)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it(`${id} collapses an UNSUPPORTED resolution to the same 720p default`, () => {
|
|
193
|
+
for (const bogus of ["4k", "2k", "360p", "768P", "not-a-resolution"]) {
|
|
194
|
+
expect(build(id, 5, bogus), bogus).toBe(`${id}:5s:720p`)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it(`${id} bills the tier it RENDERS for every spelling of the resolution`, () => {
|
|
199
|
+
// The render path is `normalizeWan3Resolution` (case-insensitive, trims),
|
|
200
|
+
// so the billing path must collapse through the SAME normalizer. KIE's own
|
|
201
|
+
// OpenAPI enum is UPPERCASE ("1080P"), which is the natural value for an
|
|
202
|
+
// integrator reading the provider docs and reaches both consumers raw:
|
|
203
|
+
// the route Zod is `z.string().optional()` and neither the credit builder
|
|
204
|
+
// nor the queue payload canonicalises it. A case-sensitive `includes`
|
|
205
|
+
// rendered 1080P while billing the 720p row — a 2x shortfall that
|
|
206
|
+
// commit_credits (refund-only) can never collect.
|
|
207
|
+
for (const spelling of ["1080P", "480P", "720P", " 720p ", "1080p", "480p", "720p", "4K", "garbage"]) {
|
|
208
|
+
const rendered = normalizeWan3Resolution(spelling).toLowerCase()
|
|
209
|
+
expect(build(id, 5, spelling), `${id} 5s "${spelling}"`).toBe(`${id}:5s:${rendered}`)
|
|
210
|
+
expect(build(id, 30, spelling), `${id} 30s "${spelling}"`).toBe(`${id}:30s:${rendered}`)
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it(`${id} snaps an off-menu duration into a seeded tier rather than the bare id`, () => {
|
|
215
|
+
// Below the floor → the first tier; above the ceiling → the last one.
|
|
216
|
+
expect(build(id, 1, "720p")).toBe(`${id}:2s:720p`)
|
|
217
|
+
expect(build(id, 45, "720p")).toBe(`${id}:30s:720p`)
|
|
218
|
+
// An omitted duration renders the KIE default of 5s.
|
|
219
|
+
expect(build(id, undefined, "720p")).toBe(`${id}:5s:720p`)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it(`${id} never emits the bare id from the generate path`, () => {
|
|
223
|
+
for (const d of [undefined, ...DURATIONS_2_TO_30]) {
|
|
224
|
+
for (const res of [undefined, "480p", "720p", "1080p", "4k"]) {
|
|
225
|
+
expect(build(id, d, res)).not.toBe(id)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
it("every catalog pricing row for the family is an id the builder can actually emit", () => {
|
|
232
|
+
for (const id of WAN_3_IDS) {
|
|
233
|
+
for (const row of MODEL_CATALOG[id].pricing ?? []) {
|
|
234
|
+
// The bare row is the reservation-time default; every other row is a
|
|
235
|
+
// duration×resolution composite the builder must be able to produce.
|
|
236
|
+
if (row.identifier === id) continue
|
|
237
|
+
const m = /^(.+):(\d+)s:(\d+p)$/.exec(row.identifier)
|
|
238
|
+
expect(m, row.identifier).not.toBeNull()
|
|
239
|
+
expect(build(m![1]!, Number(m![2]), m![3])).toBe(row.identifier)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
})
|
|
@@ -21,7 +21,10 @@ import {
|
|
|
21
21
|
T2I_TO_I2I_VARIANT,
|
|
22
22
|
isVeoProvider,
|
|
23
23
|
isMinimaxH3Provider,
|
|
24
|
+
isGeminiOmniProvider,
|
|
25
|
+
isWan3Provider,
|
|
24
26
|
normalizeMinimaxH3Resolution,
|
|
27
|
+
normalizeWan3Resolution,
|
|
25
28
|
getVideoAudioCapability,
|
|
26
29
|
} from "./model-constants.js"
|
|
27
30
|
import { isFlux2Model } from "./flux2-pricing.js"
|
|
@@ -137,9 +140,11 @@ const T2V_CREDIT_OVERRIDES: Record<string, string> = {
|
|
|
137
140
|
* @param resolution - Output resolution (used by Seedance 2 for 480p/720p pricing)
|
|
138
141
|
* @param hasVideoRef - Whether a reference video is connected (Seedance 2 uses a lower per-second rate when true)
|
|
139
142
|
*/
|
|
140
|
-
/** Gemini Omni
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
+
/** Gemini Omni duration tiers (seconds). Mirrors the `durations` of BOTH
|
|
144
|
+
* `MODEL_CATALOG["gemini-omni-video"]` and `MODEL_CATALOG["gemini-omni-flash"]`
|
|
145
|
+
* (and their `KIE_VIDEO_MODELS[*].allowedDurations`) — the family shares one
|
|
146
|
+
* ladder. Hoisted to module scope so the nearest-tier snap below doesn't
|
|
147
|
+
* reallocate on every (hot-path) call. */
|
|
143
148
|
const GEMINI_OMNI_DURATIONS = [4, 6, 8, 10]
|
|
144
149
|
|
|
145
150
|
/**
|
|
@@ -191,17 +196,21 @@ export function buildVideoCreditModelIdentifier(
|
|
|
191
196
|
return effectiveProvider
|
|
192
197
|
}
|
|
193
198
|
|
|
194
|
-
// Gemini Omni
|
|
195
|
-
// per-generation rate when a source
|
|
196
|
-
|
|
199
|
+
// Gemini Omni family (gemini-omni-video + gemini-omni-flash): priced by
|
|
200
|
+
// (resolution-band × duration), with a flat per-generation rate when a source
|
|
201
|
+
// video is supplied (V2V). Lowercase "4k". The prefix is TEMPLATED from the
|
|
202
|
+
// provider id, so a further Omni SKU needs no edit here — it only has to join
|
|
203
|
+
// GEMINI_OMNI_PROVIDERS and seed its rows. These models are deliberately NOT
|
|
204
|
+
// in DURATION_PRICED_PROVIDERS; this branch early-returns before that gate.
|
|
205
|
+
if (isGeminiOmniProvider(effectiveProvider)) {
|
|
197
206
|
if (hasVideoRef) {
|
|
198
|
-
return resolution === "4k" ?
|
|
207
|
+
return resolution === "4k" ? `${effectiveProvider}:4k:vref` : `${effectiveProvider}:vref`
|
|
199
208
|
}
|
|
200
209
|
// Snap to nearest allowed tier (NOT a min/max clamp) so off-tier durations
|
|
201
210
|
// map to a SEEDED composite; default 8 when unset.
|
|
202
211
|
const raw = parseInt(String(duration ?? 8), 10)
|
|
203
212
|
const d = Number.isNaN(raw) ? 8 : GEMINI_OMNI_DURATIONS.reduce((b, a) => (Math.abs(a - raw) < Math.abs(b - raw) ? a : b))
|
|
204
|
-
return resolution === "4k" ?
|
|
213
|
+
return resolution === "4k" ? `${effectiveProvider}:4k:${d}` : `${effectiveProvider}:${d}`
|
|
205
214
|
}
|
|
206
215
|
|
|
207
216
|
// LTX 2.3: priced by (resolution-band × duration-seconds). The RESERVE must be
|
|
@@ -281,7 +290,31 @@ export function buildVideoCreditModelIdentifier(
|
|
|
281
290
|
// guard fuzzes the whole resolution space.
|
|
282
291
|
const resTiers = RESOLUTION_DURATION_PRICING[effectiveProvider]
|
|
283
292
|
if (resTiers) {
|
|
284
|
-
|
|
293
|
+
// The "default tier" is the provider's DECLARED default when it has one
|
|
294
|
+
// (PRICING_DEFAULT_RESOLUTION — wan-3 renders 720p, not the cheapest tier),
|
|
295
|
+
// otherwise the first listed tier. Declaring it explicitly means a cosmetic
|
|
296
|
+
// reorder of the tier list can never reprice a live model, and an OMITTED
|
|
297
|
+
// *or* unsupported resolution both land on what actually renders instead of
|
|
298
|
+
// on the cheapest row (commit_credits refunds a surplus but can never
|
|
299
|
+
// collect a shortfall). Behaviour-neutral for every member without a
|
|
300
|
+
// declared default.
|
|
301
|
+
const declaredDefault = PRICING_DEFAULT_RESOLUTION[effectiveProvider]
|
|
302
|
+
const fallback = declaredDefault && resTiers.includes(declaredDefault) ? declaredDefault : resTiers[0]!
|
|
303
|
+
// Wan 3.0 renders through `normalizeWan3Resolution`, which is CASE-INSENSITIVE
|
|
304
|
+
// ("1080P" — KIE's own OpenAPI enum spelling, and the natural value for an
|
|
305
|
+
// integrator reading the provider docs — renders 1080P). The tier list here is
|
|
306
|
+
// lowercase and `Array.includes` is case-sensitive, so without this the render
|
|
307
|
+
// path and the billing path disagree: "1080P" rendered the top tier and billed
|
|
308
|
+
// the 720p row, a shortfall `commit_credits` (refund-only) can never collect.
|
|
309
|
+
// Collapsing through the SAME normalizer the runner uses makes render == billed
|
|
310
|
+
// by construction for every spelling, including garbage (→ the declared 720p).
|
|
311
|
+
// Scoped to the wan family on purpose: the other RESOLUTION_DURATION_PRICING
|
|
312
|
+
// members pass `resolution` to their wire verbatim, so case-folding their
|
|
313
|
+
// billing without probing their wire would reprice live models.
|
|
314
|
+
const requested = isWan3Provider(effectiveProvider)
|
|
315
|
+
? normalizeWan3Resolution(resolution).toLowerCase()
|
|
316
|
+
: resolution
|
|
317
|
+
const res = requested && resTiers.includes(requested) ? requested : fallback
|
|
285
318
|
identifier += `:${res}`
|
|
286
319
|
}
|
|
287
320
|
|
package/src/i18n/held-prop.ar.ts
CHANGED
|
@@ -69,6 +69,8 @@ const map: LocaleCatalogMap = {
|
|
|
69
69
|
"compass": { label: "بوصلة", description: "بوصلة بحرية محمولة، استكشاف" },
|
|
70
70
|
"bow-and-arrow": { label: "قوس وسهم", description: "قوس مشدود بسهم متأهب" },
|
|
71
71
|
"shield": { label: "درع", description: "درع محمول، عصور وسطى / خيال" },
|
|
72
|
+
// --- picker-gaps 2026-09-01 ---
|
|
73
|
+
"work-gloves": { label: "قفازات عمل", description: "قفازات عمل جلدية بالية ممسوكة باليد" },
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
export default map
|
package/src/i18n/held-prop.de.ts
CHANGED
|
@@ -60,6 +60,9 @@ const map: LocaleCatalogMap = {
|
|
|
60
60
|
"compass": { label: "Kompass", description: "Handgehaltener nautischer Kompass, Erkundung" },
|
|
61
61
|
"bow-and-arrow": { label: "Pfeil und Bogen", description: "Gespannter Bogen mit eingelegtem Pfeil" },
|
|
62
62
|
"shield": { label: "Schild", description: "Handgehaltenes Schild, mittelalterlich / Fantasy" },
|
|
63
|
+
|
|
64
|
+
// --- picker-gaps 2026-09-01 ---
|
|
65
|
+
"work-gloves": { label: "Arbeitshandschuhe", description: "Abgetragene lederne Arbeitshandschuhe in der Hand gehalten" },
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
export default map
|
package/src/i18n/held-prop.es.ts
CHANGED
|
@@ -78,6 +78,9 @@ const map: LocaleCatalogMap = {
|
|
|
78
78
|
"compass": { label: "Brújula", description: "Brújula náutica de mano, exploración" },
|
|
79
79
|
"bow-and-arrow": { label: "Arco y Flecha", description: "Arco de tiro tensado con flecha encajada" },
|
|
80
80
|
"shield": { label: "Escudo", description: "Escudo de mano, medieval / fantasía" },
|
|
81
|
+
|
|
82
|
+
// --- picker-gaps 2026-09-01 ---
|
|
83
|
+
"work-gloves": { label: "Guantes de Trabajo", description: "Guantes de cuero de trabajo desgastados sostenidos en la mano" },
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
export default map
|
package/src/i18n/held-prop.fr.ts
CHANGED
|
@@ -69,6 +69,8 @@ const map: LocaleCatalogMap = {
|
|
|
69
69
|
"compass": { label: "Boussole", description: "Boussole nautique à main, exploration" },
|
|
70
70
|
"bow-and-arrow": { label: "Arc et flèche", description: "Arc de tir à l'arc tendu avec flèche encochée" },
|
|
71
71
|
"shield": { label: "Bouclier", description: "Bouclier porté à la main, médiéval / fantasy" },
|
|
72
|
+
// --- picker-gaps 2026-09-01 ---
|
|
73
|
+
"work-gloves": { label: "Gants de travail", description: "Gants de travail en cuir usés tenus à la main" },
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
export default map
|
package/src/i18n/held-prop.he.ts
CHANGED
|
@@ -68,6 +68,9 @@ const map: LocaleCatalogMap = {
|
|
|
68
68
|
"compass": { label: "מצפן", description: "מצפן ימי מוחזק ביד, חקירה" },
|
|
69
69
|
"bow-and-arrow": { label: "קשת וחץ", description: "קשת קשתות דרוכה עם חץ על המיתר" },
|
|
70
70
|
"shield": { label: "מגן", description: "מגן מוחזק ביד, ימי הביניים / פנטזיה" },
|
|
71
|
+
|
|
72
|
+
// --- picker-gaps 2026-09-01 ---
|
|
73
|
+
"work-gloves": { label: "כפפות עבודה", description: "כפפות עבודה שחוקות מעור, מוחזקות ביד" },
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
export default map
|
package/src/i18n/held-prop.hi.ts
CHANGED
|
@@ -77,6 +77,8 @@ const map: LocaleCatalogMap = {
|
|
|
77
77
|
"compass": { label: "कम्पास", description: "हाथ का nautical कम्पास, अन्वेषण" },
|
|
78
78
|
"bow-and-arrow": { label: "धनुष-बाण", description: "तीर लगाया हुआ खींचा हुआ archery धनुष" },
|
|
79
79
|
"shield": { label: "ढाल", description: "हाथ की ढाल, मध्यकालीन / fantasy" },
|
|
80
|
+
// --- picker-gaps 2026-09-01 ---
|
|
81
|
+
"work-gloves": { label: "काम के दस्ताने", description: "हाथ में पकड़े हुए घिसे चमड़े के काम के दस्ताने" },
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
export default map
|
package/src/i18n/held-prop.ja.ts
CHANGED
|
@@ -79,6 +79,9 @@ const map: LocaleCatalogMap = {
|
|
|
79
79
|
"compass": { label: "コンパス", description: "手持ちの航海用コンパス、探検" },
|
|
80
80
|
"bow-and-arrow": { label: "弓矢", description: "矢をつがえて引き絞った弓" },
|
|
81
81
|
"shield": { label: "盾", description: "手持ちの盾、中世/ファンタジー" },
|
|
82
|
+
|
|
83
|
+
// --- picker-gaps 2026-09-01 ---
|
|
84
|
+
"work-gloves": { label: "作業用手袋", description: "手に持った使い込まれた革の作業用手袋" },
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
export default map
|