@nodaro/shared 2.18.0 → 2.20.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 +350 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +955 -564
- package/dist/index.d.ts +955 -564
- package/dist/index.js +334 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/credit-identifiers.test.ts +180 -0
- package/src/__tests__/gvp-supported-providers.test.ts +22 -1
- package/src/__tests__/image-pricing-catalog-coverage.test.ts +139 -0
- package/src/__tests__/normalize-node-params.test.ts +36 -0
- package/src/__tests__/organizations-types.test.ts +29 -1
- package/src/__tests__/prompt-length-limits.test.ts +40 -0
- package/src/__tests__/suno-credit-type.test.ts +54 -0
- package/src/__tests__/video-analysis-brief.test.ts +61 -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 +191 -21
- 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 +17 -0
- package/src/model-catalog.ts +101 -0
- package/src/model-constants.ts +251 -16
- package/src/node-default-mappings.ts +5 -0
- package/src/normalize-node-params.ts +8 -0
- package/src/organizations/types.ts +12 -0
- package/src/organizations/views.ts +114 -0
- package/src/video-analysis.ts +45 -0
- package/src/video-ui-defaults.ts +66 -0
|
@@ -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,11 +21,14 @@ 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"
|
|
28
|
-
import { MODEL_CATALOG } from "./model-catalog.js"
|
|
31
|
+
import { MODEL_CATALOG, normalizeModelInput, type ModelInputAdjustment } from "./model-catalog.js"
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* Compute composite model identifier for variable credit pricing.
|
|
@@ -77,6 +80,90 @@ export function buildCreditModelIdentifier(
|
|
|
77
80
|
return provider
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
/**
|
|
84
|
+
* An image request's catalog-snapped parameters PLUS the credit identifier
|
|
85
|
+
* priced off them.
|
|
86
|
+
*
|
|
87
|
+
* `adjustments` is the disclosure channel: every lever this changed, why, and
|
|
88
|
+
* to what. Routes return it in the 200 body so a caller learns their value was
|
|
89
|
+
* corrected instead of silently getting something else.
|
|
90
|
+
*/
|
|
91
|
+
export interface NormalizedImageGen {
|
|
92
|
+
/** Credit identifier, priced off the SNAPPED `resolution` / `quality`. */
|
|
93
|
+
identifier: string
|
|
94
|
+
/** The catalog model id the params were snapped against (post T2I→I2I swap). */
|
|
95
|
+
modelId: string
|
|
96
|
+
aspectRatio?: string
|
|
97
|
+
resolution?: string
|
|
98
|
+
quality?: string
|
|
99
|
+
/** Empty when the caller's values were already valid for `modelId`. */
|
|
100
|
+
adjustments: ModelInputAdjustment[]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Snap an image request's catalog-governed levers to a combination the model
|
|
105
|
+
* actually accepts, and price the credit identifier off the SNAPPED values.
|
|
106
|
+
*
|
|
107
|
+
* WHY THE SNAP LIVES HERE and not at the call site. Both image routes compute
|
|
108
|
+
* their credit identifier TWICE — once in the `creditGuard` preHandler (the
|
|
109
|
+
* CHECK) and once at the handler's `reserveCreditsForJob` (the DEBIT) — and a
|
|
110
|
+
* dedicated test asserts the two stay byte-identical (see
|
|
111
|
+
* `backend/src/routes/__tests__/generate-image.test.ts`, "CHECK === DEBIT").
|
|
112
|
+
* `resolution` and `quality` are pricing dimensions, so a snap applied at only
|
|
113
|
+
* one of those sites breaks that invariant, and `commit_credits` never collects
|
|
114
|
+
* an upward delta — the reserve IS the charge. Putting the snap inside the
|
|
115
|
+
* primitive makes CHECK, DEBIT and the workflow orchestrator agree by
|
|
116
|
+
* construction rather than by everyone remembering to call a normalizer.
|
|
117
|
+
*
|
|
118
|
+
* Inputs are typed `unknown` on purpose: the preHandler runs BEFORE Zod, so a
|
|
119
|
+
* caller can put a number or an object in `resolution`. Non-strings become
|
|
120
|
+
* `undefined` rather than reaching `normalizeModelInput` as a lie.
|
|
121
|
+
*
|
|
122
|
+
* Unknown model ids pass through untouched (same contract as
|
|
123
|
+
* `normalizeModelInput`) — the route's provider enum is the gate for those.
|
|
124
|
+
*/
|
|
125
|
+
export function resolveNormalizedImageGen(opts: {
|
|
126
|
+
provider: string | undefined
|
|
127
|
+
aspectRatio?: unknown
|
|
128
|
+
quality?: unknown
|
|
129
|
+
resolution?: unknown
|
|
130
|
+
renderingSpeed?: unknown
|
|
131
|
+
refCount: number
|
|
132
|
+
swapToI2i?: boolean
|
|
133
|
+
}): NormalizedImageGen {
|
|
134
|
+
const str = (v: unknown): string | undefined =>
|
|
135
|
+
typeof v === "string" && v.length > 0 ? v : undefined
|
|
136
|
+
|
|
137
|
+
const provider = str(opts.provider) ?? "nano-banana"
|
|
138
|
+
// Same swap `resolveEffectiveProvider` applies in the route: refs attached to
|
|
139
|
+
// a bare T2I provider route the run to its i2i sibling, which has its OWN
|
|
140
|
+
// catalog entry and its own lever lists.
|
|
141
|
+
const modelId =
|
|
142
|
+
opts.swapToI2i && opts.refCount > 0 ? (T2I_TO_I2I_VARIANT[provider] ?? provider) : provider
|
|
143
|
+
|
|
144
|
+
const n = normalizeModelInput(modelId, {
|
|
145
|
+
aspectRatio: str(opts.aspectRatio),
|
|
146
|
+
resolution: str(opts.resolution),
|
|
147
|
+
quality: str(opts.quality),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
identifier: buildCreditModelIdentifier(
|
|
152
|
+
modelId,
|
|
153
|
+
n.quality,
|
|
154
|
+
n.resolution,
|
|
155
|
+
str(opts.renderingSpeed),
|
|
156
|
+
undefined,
|
|
157
|
+
opts.refCount,
|
|
158
|
+
),
|
|
159
|
+
modelId,
|
|
160
|
+
aspectRatio: n.aspectRatio,
|
|
161
|
+
resolution: n.resolution,
|
|
162
|
+
quality: n.quality,
|
|
163
|
+
adjustments: n.adjustments,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
80
167
|
/**
|
|
81
168
|
* Reference-aware image-generation credit identifier — the SINGLE source of
|
|
82
169
|
* truth shared by the single-node routes (`/v1/generate-image`,
|
|
@@ -104,17 +191,7 @@ export function resolveImageGenCreditIdentifier(opts: {
|
|
|
104
191
|
refCount: number
|
|
105
192
|
swapToI2i?: boolean
|
|
106
193
|
}): string {
|
|
107
|
-
|
|
108
|
-
const effectiveProvider =
|
|
109
|
-
opts.swapToI2i && opts.refCount > 0 ? (T2I_TO_I2I_VARIANT[provider] ?? provider) : provider
|
|
110
|
-
return buildCreditModelIdentifier(
|
|
111
|
-
effectiveProvider,
|
|
112
|
-
opts.quality,
|
|
113
|
-
opts.resolution,
|
|
114
|
-
opts.renderingSpeed,
|
|
115
|
-
undefined,
|
|
116
|
-
opts.refCount,
|
|
117
|
-
)
|
|
194
|
+
return resolveNormalizedImageGen(opts).identifier
|
|
118
195
|
}
|
|
119
196
|
|
|
120
197
|
// T2V-specific credit overrides: some providers have different costs for T2V
|
|
@@ -137,9 +214,11 @@ const T2V_CREDIT_OVERRIDES: Record<string, string> = {
|
|
|
137
214
|
* @param resolution - Output resolution (used by Seedance 2 for 480p/720p pricing)
|
|
138
215
|
* @param hasVideoRef - Whether a reference video is connected (Seedance 2 uses a lower per-second rate when true)
|
|
139
216
|
*/
|
|
140
|
-
/** Gemini Omni
|
|
141
|
-
*
|
|
142
|
-
*
|
|
217
|
+
/** Gemini Omni duration tiers (seconds). Mirrors the `durations` of BOTH
|
|
218
|
+
* `MODEL_CATALOG["gemini-omni-video"]` and `MODEL_CATALOG["gemini-omni-flash"]`
|
|
219
|
+
* (and their `KIE_VIDEO_MODELS[*].allowedDurations`) — the family shares one
|
|
220
|
+
* ladder. Hoisted to module scope so the nearest-tier snap below doesn't
|
|
221
|
+
* reallocate on every (hot-path) call. */
|
|
143
222
|
const GEMINI_OMNI_DURATIONS = [4, 6, 8, 10]
|
|
144
223
|
|
|
145
224
|
/**
|
|
@@ -191,17 +270,21 @@ export function buildVideoCreditModelIdentifier(
|
|
|
191
270
|
return effectiveProvider
|
|
192
271
|
}
|
|
193
272
|
|
|
194
|
-
// Gemini Omni
|
|
195
|
-
// per-generation rate when a source
|
|
196
|
-
|
|
273
|
+
// Gemini Omni family (gemini-omni-video + gemini-omni-flash): priced by
|
|
274
|
+
// (resolution-band × duration), with a flat per-generation rate when a source
|
|
275
|
+
// video is supplied (V2V). Lowercase "4k". The prefix is TEMPLATED from the
|
|
276
|
+
// provider id, so a further Omni SKU needs no edit here — it only has to join
|
|
277
|
+
// GEMINI_OMNI_PROVIDERS and seed its rows. These models are deliberately NOT
|
|
278
|
+
// in DURATION_PRICED_PROVIDERS; this branch early-returns before that gate.
|
|
279
|
+
if (isGeminiOmniProvider(effectiveProvider)) {
|
|
197
280
|
if (hasVideoRef) {
|
|
198
|
-
return resolution === "4k" ?
|
|
281
|
+
return resolution === "4k" ? `${effectiveProvider}:4k:vref` : `${effectiveProvider}:vref`
|
|
199
282
|
}
|
|
200
283
|
// Snap to nearest allowed tier (NOT a min/max clamp) so off-tier durations
|
|
201
284
|
// map to a SEEDED composite; default 8 when unset.
|
|
202
285
|
const raw = parseInt(String(duration ?? 8), 10)
|
|
203
286
|
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" ?
|
|
287
|
+
return resolution === "4k" ? `${effectiveProvider}:4k:${d}` : `${effectiveProvider}:${d}`
|
|
205
288
|
}
|
|
206
289
|
|
|
207
290
|
// LTX 2.3: priced by (resolution-band × duration-seconds). The RESERVE must be
|
|
@@ -281,7 +364,31 @@ export function buildVideoCreditModelIdentifier(
|
|
|
281
364
|
// guard fuzzes the whole resolution space.
|
|
282
365
|
const resTiers = RESOLUTION_DURATION_PRICING[effectiveProvider]
|
|
283
366
|
if (resTiers) {
|
|
284
|
-
|
|
367
|
+
// The "default tier" is the provider's DECLARED default when it has one
|
|
368
|
+
// (PRICING_DEFAULT_RESOLUTION — wan-3 renders 720p, not the cheapest tier),
|
|
369
|
+
// otherwise the first listed tier. Declaring it explicitly means a cosmetic
|
|
370
|
+
// reorder of the tier list can never reprice a live model, and an OMITTED
|
|
371
|
+
// *or* unsupported resolution both land on what actually renders instead of
|
|
372
|
+
// on the cheapest row (commit_credits refunds a surplus but can never
|
|
373
|
+
// collect a shortfall). Behaviour-neutral for every member without a
|
|
374
|
+
// declared default.
|
|
375
|
+
const declaredDefault = PRICING_DEFAULT_RESOLUTION[effectiveProvider]
|
|
376
|
+
const fallback = declaredDefault && resTiers.includes(declaredDefault) ? declaredDefault : resTiers[0]!
|
|
377
|
+
// Wan 3.0 renders through `normalizeWan3Resolution`, which is CASE-INSENSITIVE
|
|
378
|
+
// ("1080P" — KIE's own OpenAPI enum spelling, and the natural value for an
|
|
379
|
+
// integrator reading the provider docs — renders 1080P). The tier list here is
|
|
380
|
+
// lowercase and `Array.includes` is case-sensitive, so without this the render
|
|
381
|
+
// path and the billing path disagree: "1080P" rendered the top tier and billed
|
|
382
|
+
// the 720p row, a shortfall `commit_credits` (refund-only) can never collect.
|
|
383
|
+
// Collapsing through the SAME normalizer the runner uses makes render == billed
|
|
384
|
+
// by construction for every spelling, including garbage (→ the declared 720p).
|
|
385
|
+
// Scoped to the wan family on purpose: the other RESOLUTION_DURATION_PRICING
|
|
386
|
+
// members pass `resolution` to their wire verbatim, so case-folding their
|
|
387
|
+
// billing without probing their wire would reprice live models.
|
|
388
|
+
const requested = isWan3Provider(effectiveProvider)
|
|
389
|
+
? normalizeWan3Resolution(resolution).toLowerCase()
|
|
390
|
+
: resolution
|
|
391
|
+
const res = requested && resTiers.includes(requested) ? requested : fallback
|
|
285
392
|
identifier += `:${res}`
|
|
286
393
|
}
|
|
287
394
|
|
|
@@ -332,3 +439,66 @@ export function buildMotionCreditModelIdentifier(
|
|
|
332
439
|
const resSuffix = resolution === "1080p" ? ":1080p" : ""
|
|
333
440
|
return `${base}${resSuffix}:${tier.suffix}`
|
|
334
441
|
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The Suno operations whose credit key depends on the model VERSION.
|
|
445
|
+
*
|
|
446
|
+
* `/v1/suno/generate`, `/cover` and `/extend` resolve their creditGuard
|
|
447
|
+
* identifier through `sunoCreditType(model, <operation>)` (routes/suno.ts
|
|
448
|
+
* :247-249, :335-337, :407-409). EVERY other Suno route charges a flat
|
|
449
|
+
* per-operation key and ignores the version the node carries — mashup (:648),
|
|
450
|
+
* add-instrumental (:852), add-vocals (:907), upload-extend (:1016),
|
|
451
|
+
* replace-section (:710), style-boost (:771), convert-wav (:962),
|
|
452
|
+
* lyrics (:482), music-video (:594), voice (:1218).
|
|
453
|
+
*
|
|
454
|
+
* Anything that QUOTES a Suno price — a dropdown row, a node badge, the
|
|
455
|
+
* workflow-credit estimator — must follow the same split, or it displays a
|
|
456
|
+
* credit key the route never charges.
|
|
457
|
+
*/
|
|
458
|
+
export const SUNO_VERSION_PRICED_OPERATIONS = [
|
|
459
|
+
"suno-generate",
|
|
460
|
+
"suno-cover",
|
|
461
|
+
"suno-extend",
|
|
462
|
+
] as const
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The seven Suno operations that appear behind a select/dropdown UI (model
|
|
466
|
+
* pickers, node badges, the credit estimator) — the three version-priced
|
|
467
|
+
* operations above, plus the four flat-key operations exercised by
|
|
468
|
+
* `sunoCreditType`'s own test suite. A readonly tuple so later tasks (the
|
|
469
|
+
* model dropdowns, node badges) can iterate or count it without redeclaring
|
|
470
|
+
* the list and drifting from this one.
|
|
471
|
+
*/
|
|
472
|
+
export const SUNO_SELECT_OPERATIONS = [
|
|
473
|
+
"suno-generate",
|
|
474
|
+
"suno-cover",
|
|
475
|
+
"suno-extend",
|
|
476
|
+
"suno-mashup",
|
|
477
|
+
"suno-add-instrumental",
|
|
478
|
+
"suno-add-vocals",
|
|
479
|
+
"suno-upload-extend",
|
|
480
|
+
] as const
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* OUR Nodaro credit key for a Suno operation, given the model version and the
|
|
484
|
+
* operation (which is also the node type and the BullMQ job name).
|
|
485
|
+
*
|
|
486
|
+
* This is the single source of truth for the Suno pricing contract, shared by
|
|
487
|
+
* the reservation path (routes/suno.ts creditGuard), the egress seam
|
|
488
|
+
* (workers/handlers/suno.ts `modelKey`, which must match what was reserved),
|
|
489
|
+
* both workflow-credit estimators (ee/billing/credits.ts and the frontend
|
|
490
|
+
* config-panels/helpers.ts), the seven Suno model dropdowns and the three Suno
|
|
491
|
+
* node badges. It is a billing key — NEVER a KIE provider id — which is what
|
|
492
|
+
* satisfies the B3 egress invariant.
|
|
493
|
+
*
|
|
494
|
+
* V5_5 → "suno-v5_5", V5 → "suno-v5", any other version → the operation key.
|
|
495
|
+
* A non-version-priced operation is returned unchanged.
|
|
496
|
+
*/
|
|
497
|
+
export function sunoCreditType(model: string | undefined, operation: string): string {
|
|
498
|
+
if (!(SUNO_VERSION_PRICED_OPERATIONS as readonly string[]).includes(operation)) {
|
|
499
|
+
return operation
|
|
500
|
+
}
|
|
501
|
+
if (model === "V5_5") return "suno-v5_5"
|
|
502
|
+
if (model === "V5") return "suno-v5"
|
|
503
|
+
return operation
|
|
504
|
+
}
|
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
|