@skinhub/viewer 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/EMBED.md +442 -0
  2. package/README.md +153 -0
  3. package/dist/SkinViewer.d.ts +18 -0
  4. package/dist/SkinViewer.d.ts.map +1 -0
  5. package/dist/SkinViewer.js +404 -0
  6. package/dist/SkinViewer.js.map +1 -0
  7. package/dist/index.d.ts +53 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +51 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/item.d.ts +118 -0
  12. package/dist/item.d.ts.map +1 -0
  13. package/dist/item.js +319 -0
  14. package/dist/item.js.map +1 -0
  15. package/dist/link.d.ts +30 -0
  16. package/dist/link.d.ts.map +1 -0
  17. package/dist/link.js +18 -0
  18. package/dist/link.js.map +1 -0
  19. package/dist/protocol.d.ts +231 -0
  20. package/dist/protocol.d.ts.map +1 -0
  21. package/dist/protocol.js +128 -0
  22. package/dist/protocol.js.map +1 -0
  23. package/dist/state.d.ts +107 -0
  24. package/dist/state.d.ts.map +1 -0
  25. package/dist/state.js +351 -0
  26. package/dist/state.js.map +1 -0
  27. package/dist/types.d.ts +573 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +106 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/useSkinViewer.d.ts +3 -0
  32. package/dist/useSkinViewer.d.ts.map +1 -0
  33. package/dist/useSkinViewer.js +66 -0
  34. package/dist/useSkinViewer.js.map +1 -0
  35. package/dist/weapons.d.ts +109 -0
  36. package/dist/weapons.d.ts.map +1 -0
  37. package/dist/weapons.js +260 -0
  38. package/dist/weapons.js.map +1 -0
  39. package/package.json +65 -0
  40. package/src/SkinViewer.tsx +465 -0
  41. package/src/index.ts +88 -0
  42. package/src/item.ts +373 -0
  43. package/src/link.ts +33 -0
  44. package/src/protocol.ts +241 -0
  45. package/src/state.ts +389 -0
  46. package/src/types.ts +672 -0
  47. package/src/useSkinViewer.ts +80 -0
  48. package/src/weapons.ts +284 -0
@@ -0,0 +1,80 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * *** `useSkinViewer()` - PROPS FOR STATE, A HOOK FOR VERBS. ***
5
+ *
6
+ * The owner floated this shape and it is the right one. Everything the viewer SHOWS is a prop, because
7
+ * that is what React is good at. The two or three things that are not values - "load it again", "what
8
+ * is it doing right now" - cannot be props without inventing a boolean somebody has to toggle back,
9
+ * so they are here.
10
+ *
11
+ * const viewer = useSkinViewer()
12
+ * <SkinViewer item={item} handle={viewer} loading={<Skeleton />} />
13
+ * <button onClick={viewer.reload} disabled={viewer.status === 'connecting'}>Reload</button>
14
+ *
15
+ * *** THE HOOK IS OPTIONAL AND THE COMPONENT IS COMPLETE WITHOUT IT. *** `onReady`, `onError` and
16
+ * `onChange` cover every integration that does not need a verb, and a component that only worked when
17
+ * a hook was also mounted would be a component with a hidden second half.
18
+ *
19
+ * *** `reload` IS STABLE; THE HANDLE OBJECT IS NOT. *** The object is rebuilt when `status`, `error` or
20
+ * `problems` move, because that is how a React value re-renders the tree that reads it. Put
21
+ * `viewer.reload` in a dependency array, not `viewer`.
22
+ */
23
+
24
+ import { useMemo, useRef, useState } from 'react'
25
+
26
+ import { LINK, type ViewerLink, type ViewerSnapshot } from './link.js'
27
+ import type { SkinViewerHandle } from './types.js'
28
+
29
+ const INITIAL: ViewerSnapshot = { status: 'connecting', error: null, problems: [] }
30
+
31
+ export const useSkinViewer = (): SkinViewerHandle => {
32
+ const [snapshot, setSnapshot] = useState<ViewerSnapshot>(INITIAL)
33
+
34
+ /*
35
+ * ONE LINK OBJECT FOR THE LIFE OF THE HOOK. `useRef` with a lazy fill rather than `useMemo`, because
36
+ * a memo is a cache and React is allowed to throw a cache away; this is identity, and the component
37
+ * writes to it.
38
+ */
39
+ const link = useRef<ViewerLink | null>(null)
40
+ if (link.current === null)
41
+ link.current = {
42
+ reload: () => {},
43
+ /*
44
+ * *** THE EQUALITY BAIL IS LOAD-BEARING, NOT AN OPTIMISATION. *** The component publishes from
45
+ * an effect that runs on every render; a `setState` that always produced a new object would
46
+ * re-render this hook's owner, which re-renders the component, which runs the effect, which
47
+ * publishes again - forever.
48
+ *
49
+ * *** AND THE ERROR IS COMPARED BY VALUE, NOT IDENTITY, WHICH IS THE WHOLE REASON THIS IS NOT
50
+ * A ONE-LINER. *** A subject failure (`no-item`, a link that did not decode) is DERIVED from
51
+ * this render's props, so it is a fresh object every render even when nothing has changed.
52
+ * Comparing it by identity is exactly the loop above.
53
+ *
54
+ * `problems` is compared by identity on purpose: it is the array the frame sent, and a new one
55
+ * means a new `hello`.
56
+ */
57
+ publish: next =>
58
+ setSnapshot(current =>
59
+ current.status === next.status &&
60
+ current.problems === next.problems &&
61
+ current.error?.code === next.error?.code &&
62
+ current.error?.message === next.error?.message
63
+ ? current
64
+ : next,
65
+ ),
66
+ }
67
+
68
+ const reload = useRef(() => link.current?.reload()).current
69
+
70
+ return useMemo<SkinViewerHandle>(
71
+ () => ({
72
+ reload,
73
+ status: snapshot.status,
74
+ error: snapshot.error,
75
+ problems: snapshot.problems,
76
+ [LINK]: link.current as ViewerLink,
77
+ }),
78
+ [reload, snapshot],
79
+ )
80
+ }
package/src/weapons.ts ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * WHICH WEAPON, said two ways — and the table that makes them the same question.
3
+ *
4
+ * An integrator holds one of two things. Either a row out of `@skinhub/cdn`'s `skins.json`, whose
5
+ * `weapon.id` is `'weapon_ak47'`, or a decoded inspect link, whose `defindex` is `7`. Both are the
6
+ * AK-47. `<SkinViewer>` takes either — {@link WeaponId} on the `weapon` prop, or the number on
7
+ * `defindex` — and this is the only place the two are reconciled.
8
+ *
9
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
10
+ * WHY THIS TABLE IS CHECKED IN AND NOT FETCHED, when the whole premise of `@skinhub/cdn` is that
11
+ * data derived from the CDN has to be derived at runtime.
12
+ *
13
+ * That rule is about data that CHANGES — a new finish, a new sticker capsule, a new agent. This is
14
+ * not that. A defindex is Valve's item definition index: `weapon_ak47` has been 7 since 2013 and
15
+ * cannot become anything else without breaking every inventory in the game. The table is 63 rows and
16
+ * it is derived from the export itself (`data/skins.json`, `weapon.weapon_id` → `weapon.id`,
17
+ * generated 2026-08-15, verified: 63 distinct defindexes, no defindex mapping to two DIFFERENT
18
+ * weapons — the 20 knives each carry a second `sfui_wpnhud_*` alias for their vanilla row, which
19
+ * `getWeaponModelPath` already resolves to the same model).
20
+ *
21
+ * The alternative is fetching a 4.4 MB `skins.json` to answer "what is 7", which would make the
22
+ * inspect-link path — the one the product exists for — cost four megabytes before the first frame.
23
+ *
24
+ * WHAT GOING STALE LOOKS LIKE, stated rather than discovered: Valve ships a new weapon, an integrator
25
+ * passes its defindex, and {@link weaponIdForDefindex} returns `undefined`. `<SkinViewer>` then
26
+ * reports `unknown-weapon` through `onError` and renders its error state, naming the number it could
27
+ * not resolve. That is a legible failure with an obvious fix (pass `weapon` instead, or upgrade), not
28
+ * a blank canvas.
29
+ */
30
+
31
+ /**
32
+ * Every weapon the viewer can render, as the CS2 econ item id.
33
+ *
34
+ * This is `skin.weapon.id` in `@skinhub/cdn`'s `skins.json` rows, verbatim — so
35
+ * `<SkinViewer weapon={row.weapon.id} paintIndex={…} />` typechecks against a catalogue row with no
36
+ * conversion. Type it into an editor and autocomplete lists all 71.
37
+ */
38
+ export const WEAPON_IDS = [
39
+ // Pistols
40
+ 'weapon_cz75a',
41
+ 'weapon_deagle',
42
+ 'weapon_elite',
43
+ 'weapon_fiveseven',
44
+ 'weapon_glock',
45
+ 'weapon_hkp2000',
46
+ 'weapon_p250',
47
+ 'weapon_revolver',
48
+ 'weapon_tec9',
49
+ 'weapon_usp_silencer',
50
+ // SMGs
51
+ 'weapon_bizon',
52
+ 'weapon_mac10',
53
+ 'weapon_mp5sd',
54
+ 'weapon_mp7',
55
+ 'weapon_mp9',
56
+ 'weapon_p90',
57
+ 'weapon_ump45',
58
+ // Rifles
59
+ 'weapon_ak47',
60
+ 'weapon_aug',
61
+ 'weapon_famas',
62
+ 'weapon_galilar',
63
+ 'weapon_m4a1',
64
+ 'weapon_m4a1_silencer',
65
+ 'weapon_sg556',
66
+ // Snipers
67
+ 'weapon_awp',
68
+ 'weapon_g3sg1',
69
+ 'weapon_scar20',
70
+ 'weapon_ssg08',
71
+ // Heavy
72
+ 'weapon_m249',
73
+ 'weapon_mag7',
74
+ 'weapon_negev',
75
+ 'weapon_nova',
76
+ 'weapon_sawedoff',
77
+ 'weapon_xm1014',
78
+ // Other
79
+ 'weapon_taser',
80
+ // Knives
81
+ 'weapon_bayonet',
82
+ 'weapon_knife_butterfly',
83
+ 'weapon_knife_canis',
84
+ 'weapon_knife_cord',
85
+ 'weapon_knife_css',
86
+ 'weapon_knife_falchion',
87
+ 'weapon_knife_flip',
88
+ 'weapon_knife_gut',
89
+ 'weapon_knife_gypsy_jackknife',
90
+ 'weapon_knife_karambit',
91
+ 'weapon_knife_kukri',
92
+ 'weapon_knife_m9_bayonet',
93
+ 'weapon_knife_outdoor',
94
+ 'weapon_knife_push',
95
+ 'weapon_knife_skeleton',
96
+ 'weapon_knife_stiletto',
97
+ 'weapon_knife_survival_bowie',
98
+ 'weapon_knife_tactical',
99
+ 'weapon_knife_ursus',
100
+ 'weapon_knife_widowmaker',
101
+ // Gloves. A pair of gloves is the SUBJECT here — the thing on screen, framed and orbitable.
102
+ // Putting a pair ON an operator in the `operator`/`firstPerson` views is not supported; see
103
+ // `SkinViewerProps.operator`.
104
+ 'leather_handwraps',
105
+ 'motorcycle_gloves',
106
+ 'slick_gloves',
107
+ 'specialist_gloves',
108
+ 'sporty_gloves',
109
+ 'studded_bloodhound_gloves',
110
+ 'studded_brokenfang_gloves',
111
+ 'studded_hydra_gloves',
112
+ ] as const
113
+
114
+ export type KnownWeaponId = (typeof WEAPON_IDS)[number]
115
+
116
+ /**
117
+ * `'weapon_ak47'`.
118
+ *
119
+ * The `(string & {})` arm is deliberate and is not a widening mistake: it keeps autocomplete listing
120
+ * the 71 known ids while still ACCEPTING an id this build has never heard of, so a new weapon in a
121
+ * fresh export renders the day it ships rather than the day the package is upgraded. An unknown id
122
+ * that the asset export also does not know resolves to no model, which surfaces as `unknown-weapon`.
123
+ */
124
+ export type WeaponId = KnownWeaponId | (string & {})
125
+
126
+ /**
127
+ * Item definition index → econ item id. See the file header for why this is checked in.
128
+ *
129
+ * The 20 knives are listed under their `weapon_*` id rather than the `sfui_wpnhud_*` alias that also
130
+ * appears on their vanilla row; both resolve to the same GLB.
131
+ */
132
+ export const WEAPON_ID_BY_DEFINDEX: Readonly<Record<number, KnownWeaponId>> = {
133
+ 1: 'weapon_deagle',
134
+ 2: 'weapon_elite',
135
+ 3: 'weapon_fiveseven',
136
+ 4: 'weapon_glock',
137
+ 7: 'weapon_ak47',
138
+ 8: 'weapon_aug',
139
+ 9: 'weapon_awp',
140
+ 10: 'weapon_famas',
141
+ 11: 'weapon_g3sg1',
142
+ 13: 'weapon_galilar',
143
+ 14: 'weapon_m249',
144
+ 16: 'weapon_m4a1',
145
+ 17: 'weapon_mac10',
146
+ 19: 'weapon_p90',
147
+ 23: 'weapon_mp5sd',
148
+ 24: 'weapon_ump45',
149
+ 25: 'weapon_xm1014',
150
+ 26: 'weapon_bizon',
151
+ 27: 'weapon_mag7',
152
+ 28: 'weapon_negev',
153
+ 29: 'weapon_sawedoff',
154
+ 30: 'weapon_tec9',
155
+ 31: 'weapon_taser',
156
+ 32: 'weapon_hkp2000',
157
+ 33: 'weapon_mp7',
158
+ 34: 'weapon_mp9',
159
+ 35: 'weapon_nova',
160
+ 36: 'weapon_p250',
161
+ 38: 'weapon_scar20',
162
+ 39: 'weapon_sg556',
163
+ 40: 'weapon_ssg08',
164
+ 60: 'weapon_m4a1_silencer',
165
+ 61: 'weapon_usp_silencer',
166
+ 63: 'weapon_cz75a',
167
+ 64: 'weapon_revolver',
168
+ 500: 'weapon_bayonet',
169
+ 503: 'weapon_knife_css',
170
+ 505: 'weapon_knife_flip',
171
+ 506: 'weapon_knife_gut',
172
+ 507: 'weapon_knife_karambit',
173
+ 508: 'weapon_knife_m9_bayonet',
174
+ 509: 'weapon_knife_tactical',
175
+ 512: 'weapon_knife_falchion',
176
+ 514: 'weapon_knife_survival_bowie',
177
+ 515: 'weapon_knife_butterfly',
178
+ 516: 'weapon_knife_push',
179
+ 517: 'weapon_knife_cord',
180
+ 518: 'weapon_knife_canis',
181
+ 519: 'weapon_knife_ursus',
182
+ 520: 'weapon_knife_gypsy_jackknife',
183
+ 521: 'weapon_knife_outdoor',
184
+ 522: 'weapon_knife_stiletto',
185
+ 523: 'weapon_knife_widowmaker',
186
+ 525: 'weapon_knife_skeleton',
187
+ 526: 'weapon_knife_kukri',
188
+ 4725: 'studded_brokenfang_gloves',
189
+ 5027: 'studded_bloodhound_gloves',
190
+ 5030: 'sporty_gloves',
191
+ 5031: 'slick_gloves',
192
+ 5032: 'leather_handwraps',
193
+ 5033: 'motorcycle_gloves',
194
+ 5034: 'specialist_gloves',
195
+ 5035: 'studded_hydra_gloves',
196
+ }
197
+
198
+ /**
199
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
200
+ * *** THE HUD ALIASES — the difference between a knife and an empty canvas. ***
201
+ *
202
+ * `@skinhub/cdn`'s `skins.json` carries TWO ids for every knife. The painted rows say
203
+ * `weapon.id === 'weapon_bayonet'`; the ONE vanilla row per knife says
204
+ * `weapon.id === 'sfui_wpnhud_knifebayonet'` — the HUD string, not the item name. Both share
205
+ * `weapon.weapon_id === 500`. There are 83 distinct `weapon.id` values across 63 defindexes, and the
206
+ * 20 extras are exactly this.
207
+ *
208
+ * So an integrator doing the most natural thing there is —
209
+ *
210
+ * const row = findSkin(skins, { defindex, paintindex })
211
+ * <SkinViewer weaponId={row.weapon.id} … />
212
+ *
213
+ * — hands this component a HUD string for all twenty vanilla knives and nothing else.
214
+ *
215
+ * *** THE ALIAS IS ACCEPTED AND FOLDED, NOT REJECTED. *** The mapping is 1:1 and unambiguous, the
216
+ * renderer's own model table already resolves the same twenty strings, and failing on a value the data
217
+ * package hands you from its documented API would only mean the two packages disagree about what a
218
+ * Bayonet is called. Folding it here additionally makes {@link defindexForWeaponId} answer for those
219
+ * rows, so an inspect link built back out of a vanilla knife carries 500 rather than 0.
220
+ *
221
+ * An id that is neither a known weapon nor a known alias still fails VISIBLY — `<SkinViewer>` reports
222
+ * `unknown-weapon` and renders its error state naming the string it could not resolve. Rendering
223
+ * nothing, silently, is the one outcome this table exists to prevent.
224
+ *
225
+ * Deliberately NOT in {@link WEAPON_IDS}: these are accepted, not recommended, and an editor should
226
+ * not offer `sfui_wpnhud_knifekaram` to somebody typing a weapon id.
227
+ */
228
+ export const WEAPON_ID_ALIASES: Readonly<Record<string, KnownWeaponId>> = {
229
+ sfui_wpnhud_knifebayonet: 'weapon_bayonet',
230
+ sfui_wpnhud_knife_butterfly: 'weapon_knife_butterfly',
231
+ sfui_wpnhud_knife_canis: 'weapon_knife_canis',
232
+ sfui_wpnhud_knife_cord: 'weapon_knife_cord',
233
+ sfui_wpnhud_knifecss: 'weapon_knife_css',
234
+ sfui_wpnhud_knife_falchion_advanced: 'weapon_knife_falchion',
235
+ sfui_wpnhud_knifeflip: 'weapon_knife_flip',
236
+ sfui_wpnhud_knifegut: 'weapon_knife_gut',
237
+ sfui_wpnhud_knife_gypsy_jackknife: 'weapon_knife_gypsy_jackknife',
238
+ sfui_wpnhud_knifekaram: 'weapon_knife_karambit',
239
+ sfui_wpnhud_knife_kukri: 'weapon_knife_kukri',
240
+ sfui_wpnhud_knifem9: 'weapon_knife_m9_bayonet',
241
+ sfui_wpnhud_knife_outdoor: 'weapon_knife_outdoor',
242
+ sfui_wpnhud_knife_push: 'weapon_knife_push',
243
+ sfui_wpnhud_knife_skeleton: 'weapon_knife_skeleton',
244
+ sfui_wpnhud_knife_stiletto: 'weapon_knife_stiletto',
245
+ sfui_wpnhud_knife_survival_bowie: 'weapon_knife_survival_bowie',
246
+ sfui_wpnhud_knifetactical: 'weapon_knife_tactical',
247
+ sfui_wpnhud_knife_ursus: 'weapon_knife_ursus',
248
+ sfui_wpnhud_knife_widowmaker: 'weapon_knife_widowmaker',
249
+ }
250
+
251
+ const KNOWN_WEAPON_IDS: ReadonlySet<string> = new Set<string>(WEAPON_IDS)
252
+
253
+ /**
254
+ * Fold a HUD alias onto the item id. Everything else passes through untouched, including ids this
255
+ * build has never heard of — see {@link WeaponId} for why an unknown id is not an error here.
256
+ */
257
+ export const normalizeWeaponId = (weapon: WeaponId): WeaponId => WEAPON_ID_ALIASES[weapon] ?? weapon
258
+
259
+ /** True for an id this build can name. `false` is what makes `<SkinViewer>` report `unknown-weapon`. */
260
+ export const isKnownWeaponId = (weapon: WeaponId): boolean => KNOWN_WEAPON_IDS.has(normalizeWeaponId(weapon))
261
+
262
+ const DEFINDEX_BY_WEAPON_ID: Readonly<Record<string, number>> = Object.fromEntries(
263
+ Object.entries(WEAPON_ID_BY_DEFINDEX).map(([defindex, id]) => [id, Number(defindex)]),
264
+ )
265
+
266
+ /**
267
+ * `7` → `'weapon_ak47'`, or `undefined` for a defindex this build does not know.
268
+ *
269
+ * NEVER RETURNS AN ALIAS — the table is keyed on `weapon_id`, which is the whole reason to build it
270
+ * that way rather than off the first `weapon.id` a scan happens to hit.
271
+ */
272
+ export const weaponIdForDefindex = (defindex: number): KnownWeaponId | undefined => WEAPON_ID_BY_DEFINDEX[defindex]
273
+
274
+ /**
275
+ * `'weapon_ak47'` → `7`, or `undefined`.
276
+ *
277
+ * The inverse, for building an inspect link back out of what the viewer is showing — see
278
+ * {@link toPlacement}, exported from the package root. Resolves HUD aliases, so a vanilla Bayonet round-trips to 500 rather than to 0.
279
+ */
280
+ export const defindexForWeaponId = (weapon: WeaponId): number | undefined =>
281
+ DEFINDEX_BY_WEAPON_ID[normalizeWeaponId(weapon)]
282
+
283
+ /** Gloves take a different renderer and carry no stickers, charm, counter or name plate. */
284
+ export const isGloveId = (weapon: WeaponId): boolean => weapon.endsWith('_gloves') || weapon === 'leather_handwraps'