@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.
- package/EMBED.md +442 -0
- package/README.md +153 -0
- package/dist/SkinViewer.d.ts +18 -0
- package/dist/SkinViewer.d.ts.map +1 -0
- package/dist/SkinViewer.js +404 -0
- package/dist/SkinViewer.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/item.d.ts +118 -0
- package/dist/item.d.ts.map +1 -0
- package/dist/item.js +319 -0
- package/dist/item.js.map +1 -0
- package/dist/link.d.ts +30 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +18 -0
- package/dist/link.js.map +1 -0
- package/dist/protocol.d.ts +231 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +128 -0
- package/dist/protocol.js.map +1 -0
- package/dist/state.d.ts +107 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +351 -0
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +573 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +106 -0
- package/dist/types.js.map +1 -0
- package/dist/useSkinViewer.d.ts +3 -0
- package/dist/useSkinViewer.d.ts.map +1 -0
- package/dist/useSkinViewer.js +66 -0
- package/dist/useSkinViewer.js.map +1 -0
- package/dist/weapons.d.ts +109 -0
- package/dist/weapons.d.ts.map +1 -0
- package/dist/weapons.js +260 -0
- package/dist/weapons.js.map +1 -0
- package/package.json +65 -0
- package/src/SkinViewer.tsx +465 -0
- package/src/index.ts +88 -0
- package/src/item.ts +373 -0
- package/src/link.ts +33 -0
- package/src/protocol.ts +241 -0
- package/src/state.ts +389 -0
- package/src/types.ts +672 -0
- package/src/useSkinViewer.ts +80 -0
- package/src/weapons.ts +284 -0
package/EMBED.md
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# Embedding the SkinHub viewer
|
|
2
|
+
|
|
3
|
+
The viewer is a page on our origin that you put in an `<iframe>`. There is nothing to install, no key,
|
|
4
|
+
no account, and no signup. Two ways to drive it:
|
|
5
|
+
|
|
6
|
+
| | what it needs from you | what you get |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| **The URL** | an `<iframe src>` | the complete initial picture: item, float, seed, stickers, charm, view, agent, gloves, lighting, quality |
|
|
9
|
+
| **`postMessage`** | ~10 lines of JavaScript | live updates on top of that, plus `ready` / `error` / `change` events |
|
|
10
|
+
|
|
11
|
+
**The URL alone is a supported, first-class integration.** If your stack is PHP, Rails, Laravel, plain
|
|
12
|
+
HTML or anything else that renders on a server, you can interpolate an item into an `src` attribute
|
|
13
|
+
and be finished. Everything on this page above the "Live updates" heading works with JavaScript
|
|
14
|
+
disabled.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. The smallest thing that works
|
|
19
|
+
|
|
20
|
+
```html
|
|
21
|
+
<iframe
|
|
22
|
+
src="https://skinhub.gg/frame?weapon=weapon_ak47&paint=1449"
|
|
23
|
+
style="width: 640px; height: 420px; border: 0; background: transparent"
|
|
24
|
+
title="AK-47 | AUTOEXEC"
|
|
25
|
+
></iframe>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
With no parameters at all you get AK-47 | AUTOEXEC. That is the smoke test, not your item - if you see
|
|
29
|
+
it when you did not ask for it, your identity parameters did not resolve. See
|
|
30
|
+
[what the frame tells you](#8-debugging-a-url).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 2. Naming the item
|
|
35
|
+
|
|
36
|
+
Three ways in. Pick whichever matches what your database already holds.
|
|
37
|
+
|
|
38
|
+
### `?weapon=` + `?paint=` - the model key and the paint index
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
?weapon=weapon_ak47&paint=1449
|
|
42
|
+
?weapon=weapon_awp&paint=344
|
|
43
|
+
?weapon=weapon_knife_karambit&paint=413
|
|
44
|
+
?weapon=sporty_gloves&paint=10038
|
|
45
|
+
?weapon=weapon_ak47&paint=0 ← vanilla, no finish
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The fastest and the most reliable: no catalogue lookup, no name matching, nothing that can go stale.
|
|
49
|
+
`paint=0` is a real value and renders the bare model. Gloves are a legitimate subject and use the same
|
|
50
|
+
two parameters.
|
|
51
|
+
|
|
52
|
+
### `?weapon=` + `?skin=` - our own slugs
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
?weapon=ak-47&skin=asiimov
|
|
56
|
+
?weapon=awp&skin=dragon-lore
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The pair from our own item URLs. Underscores mean a model key, hyphens mean a slug, so the two forms
|
|
60
|
+
cannot be confused.
|
|
61
|
+
|
|
62
|
+
### `?hash=` - Steam's `market_hash_name`
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
?hash=AK-47%20%7C%20Asiimov
|
|
66
|
+
?hash=%E2%98%85%20Karambit%20%7C%20Fade
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Drop the exterior and any `StatTrak™` / `Souvenir` prefix; keep the `★`. For an integrator whose data
|
|
70
|
+
came out of a Steam inventory and has no paint index in it.
|
|
71
|
+
|
|
72
|
+
One name can be several items - the Dopplers are eight rows called `★ Karambit | Doppler`, one per
|
|
73
|
+
phase, and the phase lives only in the paint index. `?hash=` takes the lowest paint index of the
|
|
74
|
+
candidates. **If you know the phase, send `?paint=` as well; it wins.**
|
|
75
|
+
|
|
76
|
+
### `?legacy=1`
|
|
77
|
+
|
|
78
|
+
Loads the legacy mesh variant. Most finishes declare this themselves and it is ignored; pass it for
|
|
79
|
+
those that do not, from your catalogue's `legacy_model` column. It changes which textures apply, so
|
|
80
|
+
the wrong value is the wrong picture rather than a detail.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 3. Configuring the item
|
|
85
|
+
|
|
86
|
+
### `?i=` - everything at once, from an inspect link
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
?weapon=weapon_ak47&paint=1449&i=001800200038D7B6C5F1034095054800...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`?i=` carries float, seed, StatTrak and its count, the name plate, all five stickers with their
|
|
93
|
+
offsets, rotation, scale and wear, and the charm with its own pattern - encoded exactly as CS2's own
|
|
94
|
+
inspect link encodes them. **If you hold an inspect link, this is the parameter to use.** It
|
|
95
|
+
round-trips exactly: what you put in is what comes back out.
|
|
96
|
+
|
|
97
|
+
A whole link is accepted, not just the hex:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
?i=steam%3A%2F%2Frungame%2F730%2F.....%2F%2Bcsgo_econ_action_preview%20001800...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`?i=` does **not** name the weapon. Send `?weapon=`/`?paint=` alongside it.
|
|
104
|
+
|
|
105
|
+
### The same fields as plain values
|
|
106
|
+
|
|
107
|
+
For data that lives in your own columns:
|
|
108
|
+
|
|
109
|
+
| parameter | meaning |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `float=0.2738` | wear, `0`..`1`. Omitted renders at the kit's own best condition, which is the flattering example of the item |
|
|
112
|
+
| `seed=661` | paint seed |
|
|
113
|
+
| `st=1337` | StatTrak counter. `st=0` is a real, freshly-minted counter; `st=-1` removes the module; omitted means no module |
|
|
114
|
+
| `nametag=SHAREME` | the name plate. Empty or whitespace removes it |
|
|
115
|
+
|
|
116
|
+
Not every finish runs `0`..`1`. A kit declares its own wear range and the renderer clamps into it, so
|
|
117
|
+
`float=0.9` on a kit that stops at `0.5` renders at `0.5` rather than failing.
|
|
118
|
+
|
|
119
|
+
**Order matters in exactly one place:** a plain `float=` / `seed=` / `st=` / `nametag=` beats the same
|
|
120
|
+
field inside `?i=`, because the explicit one is the more specific statement.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 4. How it is shown
|
|
125
|
+
|
|
126
|
+
| parameter | values | default |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `view` | `gun`, `hands`, `agent` (`viewmodel` is accepted for `hands`) | `gun` |
|
|
129
|
+
| `agent` | an agent id, e.g. `5203` | `5036` (Default T) |
|
|
130
|
+
| `pose` | a main-menu clip leaf name | matches the weapon |
|
|
131
|
+
| `glove` | `type:paintIndex[:float[:seed]]`, e.g. `sporty_gloves:10038:0.31:77`. `none` for the wearer's own default pair | the wearer's own pair |
|
|
132
|
+
| `slot` | `0`..`4` for a sticker, `5` for the charm, `-1` for none - which one opens with its handles showing | `-1` |
|
|
133
|
+
|
|
134
|
+
`agent`, `pose` and `glove` are inert in the `gun` view; there is nobody on screen to wear them.
|
|
135
|
+
|
|
136
|
+
**Omitting `glove` is not "bare hands".** Every agent ships a default pair of their own, and that is
|
|
137
|
+
what you get.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 5. Looks and performance
|
|
142
|
+
|
|
143
|
+
### Lighting and background
|
|
144
|
+
|
|
145
|
+
| parameter | values | default |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `map` | a map name (`Ancient`, `Mirage`, `Nuke`, ...), or `none` for our calibrated reference rig | `Ancient` |
|
|
148
|
+
| `time` | `Day`, `Night`. Falls back when a map has only one | `Night` |
|
|
149
|
+
| `rain` | `0` / `1` - wet surfaces on maps whose own data says it rains | `1` |
|
|
150
|
+
| `bg` | `transparent`, or a map name to show that map's video plate | `transparent` |
|
|
151
|
+
|
|
152
|
+
**`map` is the light, `bg` is the picture behind it, and they are separate on purpose.** Naming a map
|
|
153
|
+
lights your item with the probe and sun CS2 bakes into that map's own menu scene. The common embed is
|
|
154
|
+
`?map=Mirage` with the default transparent background: Mirage's light, your page behind it.
|
|
155
|
+
|
|
156
|
+
`bg=transparent` mounts no video element at all, so nobody downloads a 30-60 MB file they cannot see.
|
|
157
|
+
|
|
158
|
+
### Quality
|
|
159
|
+
|
|
160
|
+
| parameter | values | default | notes |
|
|
161
|
+
|---|---|---|---|
|
|
162
|
+
| `bloom` | `0` disables it entirely, `1` is CS2's own look, `2` is strong | `1` | `0` mounts no post-processing chain and allocates no offscreen target, so it is genuinely the cheaper path |
|
|
163
|
+
| `spill` | `0`..`n` - how much bloom is allowed past the item's outline | `0` | our own site ships `1`; the embed ships `0`, because a halo leaking onto your product page is not something your layout asked for |
|
|
164
|
+
| `scale` | `0.25`..`3`, or `Performance` / `Balanced` / `Native` | `1.5` | a **ceiling** on `devicePixelRatio`. The single biggest performance lever - `2` is four times the fragments of `1`. **A page with a grid of viewers wants this low.** |
|
|
165
|
+
| `aa` | `0` / `1` | `1` | only reachable while `bloom` is above `0`; with bloom off the canvas antialiases itself and cannot be changed |
|
|
166
|
+
| `shadows` | `0` / `1` | `0` | the item shadowing itself. Off is the reference picture |
|
|
167
|
+
| `fov` | `1`..`179` degrees | `26` | a long lens, which is what keeps a rifle from looking bent. Changing it re-frames rather than zooms. Ignored in `hands`, which uses CS2's own `viewmodel_fov` |
|
|
168
|
+
| `zoom` | `0.05`..`20` | `1` | a **multiplier** on the solved fit distance, never a distance - `1.2` is 20% closer. A fixed distance cannot be right in two differently-shaped containers |
|
|
169
|
+
|
|
170
|
+
### What the user may do
|
|
171
|
+
|
|
172
|
+
| parameter | default | |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `orbit` | `1` | drag to turn the item, right-drag to pan. Always off in `hands` |
|
|
175
|
+
| `wheel` | `1` | wheel to dolly |
|
|
176
|
+
| `dragstickers` | `0` | let the user move and rotate a placed sticker |
|
|
177
|
+
| `dragcharm` | `0` | the same for the charm |
|
|
178
|
+
| `stickergizmo` | `0` | draw the outline and handles on the open sticker |
|
|
179
|
+
| `charmgizmo` | `0` | draw the charm's billboard |
|
|
180
|
+
| `gizmocolor` | - | any CSS colour for the gizmo chrome |
|
|
181
|
+
| `gizmoshadow` | - | the dark under-stroke that keeps it readable over a pale kit |
|
|
182
|
+
|
|
183
|
+
**Editing is off by default and dragging and drawing are independent.** Enabling `dragstickers`
|
|
184
|
+
without `stickergizmo` gives invisible hit targets, for a host drawing its own guides. Enabling
|
|
185
|
+
`stickergizmo` without `dragstickers` shows where a sticker sits and does not let anyone move it. Both
|
|
186
|
+
combinations are supported.
|
|
187
|
+
|
|
188
|
+
**If you turn dragging on, listen for the `change` event** (below), or you will not be able to save
|
|
189
|
+
what your user did.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 6. Live updates
|
|
194
|
+
|
|
195
|
+
Everything above can also be changed at runtime, without reloading the frame.
|
|
196
|
+
|
|
197
|
+
```html
|
|
198
|
+
<iframe id="viewer" src="https://skinhub.gg/frame?weapon=weapon_ak47&paint=1449"></iframe>
|
|
199
|
+
<script>
|
|
200
|
+
const FRAME_ORIGIN = 'https://skinhub.gg'
|
|
201
|
+
const viewer = document.getElementById('viewer')
|
|
202
|
+
|
|
203
|
+
const set = patch =>
|
|
204
|
+
viewer.contentWindow.postMessage(
|
|
205
|
+
{ channel: 'skinhub-viewer', v: 1, from: 'host', type: 'set', patch },
|
|
206
|
+
FRAME_ORIGIN,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
window.addEventListener('message', event => {
|
|
210
|
+
if (event.source !== viewer.contentWindow) return
|
|
211
|
+
const message = event.data
|
|
212
|
+
if (!message || message.channel !== 'skinhub-viewer' || message.from !== 'viewer') return
|
|
213
|
+
|
|
214
|
+
switch (message.type) {
|
|
215
|
+
case 'hello': console.log('viewer listening, protocol', message.v, message.state, message.problems); break
|
|
216
|
+
case 'ready': console.log('finished loading'); break
|
|
217
|
+
case 'error': console.error(message.error.code, message.error.message); break
|
|
218
|
+
case 'change': saveToYourDatabase(message.item); break
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// live, no reload:
|
|
223
|
+
document.querySelector('#float').oninput = e => set({ item: { float: +e.target.value } })
|
|
224
|
+
</script>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### The envelope
|
|
228
|
+
|
|
229
|
+
Every message in both directions carries these three fields:
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
{ channel: 'skinhub-viewer', v: 1, from: 'host' | 'viewer', type: ... }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
- `channel` - anything without it is not ours and is ignored in silence. `postMessage` is a shared bus;
|
|
236
|
+
React DevTools, HMR and browser extensions all post into frames.
|
|
237
|
+
- `v` - the protocol version. See [versioning](#7-versioning).
|
|
238
|
+
- `from` - the direction. `host` for messages you send, `viewer` for messages you receive.
|
|
239
|
+
|
|
240
|
+
### Host → viewer
|
|
241
|
+
|
|
242
|
+
| type | payload | |
|
|
243
|
+
|---|---|---|
|
|
244
|
+
| `set` | `patch` | any subset of the state. See below |
|
|
245
|
+
| `hello` | - | optional. Asks the viewer to announce itself again. Useful if you attached your listener after the frame had already loaded |
|
|
246
|
+
|
|
247
|
+
### The patch
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
set({
|
|
251
|
+
item: { weaponType, paintIndex, legacyModel, float, seed, statTrak, nameTag, stickers },
|
|
252
|
+
view: 'gun' | 'hands' | 'agent',
|
|
253
|
+
agent: { id, pose },
|
|
254
|
+
gloves: { type, paintIndex, float, seed } | null,
|
|
255
|
+
settings: {
|
|
256
|
+
camera: { fov, defaultZoom },
|
|
257
|
+
quality: { bloom, bloomSpill, renderScale, antialias, shadows },
|
|
258
|
+
environment: { map, timeOfDay, rain, background },
|
|
259
|
+
overlays: { stickerGizmo, charmGizmo, gizmoStyle: { color, shadowColor } },
|
|
260
|
+
},
|
|
261
|
+
interactions: { orbit, zoom, dragStickers, dragCharm },
|
|
262
|
+
editingSlot: -1,
|
|
263
|
+
})
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**Everything merges by field. A key you leave out is left alone.** `set({ item: { float: 0.3 } })`
|
|
267
|
+
keeps your weapon, your stickers and your name plate. `set({ settings: { quality: { bloom: 0 } } })`
|
|
268
|
+
keeps the camera and the environment. This is not just convenience - see
|
|
269
|
+
[cheap vs reload](#cheap-vs-reload).
|
|
270
|
+
|
|
271
|
+
**`null` is a value, not an absence**, wherever it means something: `nameTag: null` is no plate,
|
|
272
|
+
`statTrak: false` is no counter, `gloves: null` is the wearer's own default pair, `agent.pose: null` is
|
|
273
|
+
"match the weapon", `settings.environment.map: null` is our calibrated reference rig.
|
|
274
|
+
|
|
275
|
+
**Nothing is coerced.** `float: '0.3'` is rejected, not parsed. You get an `error` event naming the
|
|
276
|
+
field, the field keeps its previous value, and the rest of the patch is applied.
|
|
277
|
+
|
|
278
|
+
### Viewer → host
|
|
279
|
+
|
|
280
|
+
| type | payload | when |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| `hello` | `state`, `problems` | on mount, and in reply to your `hello`. `state` is everything the viewer resolved; `problems` lists any URL parameter it could not read |
|
|
283
|
+
| `ready` | - | the item is on screen and textured |
|
|
284
|
+
| `error` | `error: { code, message }` | see below |
|
|
285
|
+
| `change` | `item` | the user moved a sticker or the charm. Fires on every pointer move during a drag |
|
|
286
|
+
| `editing-slot` | `slot` | the user clicked a sticker or the charm to open it |
|
|
287
|
+
| `resize` | `width`, `height`, `dpr` | the frame's own box changed, throttled to one per animation frame |
|
|
288
|
+
|
|
289
|
+
`error` codes: `render-failed`, `bad-inspect-link`, `bad-message`, `protocol-mismatch`.
|
|
290
|
+
|
|
291
|
+
**`ready` fires again after every reload, not once per page.** It means "stopped loading", so a weapon
|
|
292
|
+
change or a view change raises the loading gate and lowers it again. Treat it as a level, not an edge.
|
|
293
|
+
|
|
294
|
+
**`hello` may arrive more than once.** It is idempotent - it carries the whole state - so read it as
|
|
295
|
+
"the viewer is listening", not "the viewer has just started".
|
|
296
|
+
|
|
297
|
+
**`change` hands back a complete item, not a diff.** Store it verbatim and pass it straight back in.
|
|
298
|
+
It does not fire for your own `set` calls; it is only ever the user talking.
|
|
299
|
+
|
|
300
|
+
### Cheap vs reload
|
|
301
|
+
|
|
302
|
+
This is the part that decides whether the embed feels like a component or like an iframe.
|
|
303
|
+
|
|
304
|
+
| changing this | |
|
|
305
|
+
|---|---|
|
|
306
|
+
| `float`, `seed`, `statTrak`, `nameTag`, stickers, the charm, **anything under `settings` or `interactions`** | **updates in place.** No loading card, no blank frame, no dropped frames. Drive it from a slider at 60 Hz |
|
|
307
|
+
| `item.weaponType`, `item.paintIndex`, `item.legacyModel`, `view`, and `agent.id` in the `agent` view | **reloads.** The viewer covers itself until the new model has actually drawn, and shows nothing rather than something half-built |
|
|
308
|
+
|
|
309
|
+
Measured across the frame boundary: 140 `set` messages carrying a moving float, plus seed, StatTrak,
|
|
310
|
+
a name plate, a bloom change and a map change, produced **zero covered frames** over 93 drawn frames
|
|
311
|
+
and 979 samples.
|
|
312
|
+
|
|
313
|
+
Two consequences for you:
|
|
314
|
+
|
|
315
|
+
- **Send partial patches.** `set({ item: { float } })` is cheap; a patch that restates `weaponType`
|
|
316
|
+
with the same value is also cheap, but one that restates it with a *different* value reloads.
|
|
317
|
+
- **A patch that changes nothing costs nothing.** Re-sending an identical value does not even
|
|
318
|
+
re-render.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## 7. Versioning
|
|
323
|
+
|
|
324
|
+
`v` is an integer. **If it does not match the version the embed speaks, the viewer renders nothing** -
|
|
325
|
+
no canvas, no partial picture - and sends you an `error` with code `protocol-mismatch` and a sentence
|
|
326
|
+
saying which side is out of date. It does not recover; later messages are ignored.
|
|
327
|
+
|
|
328
|
+
That is deliberate, and it is the one place we are strict. A viewer that half-understood a message
|
|
329
|
+
would render a half-correct item, and a subtly wrong picture is worse than a blank one with an
|
|
330
|
+
explanation.
|
|
331
|
+
|
|
332
|
+
**A raw `<iframe src>` can never hit this.** It never sends a message, so it has no version to
|
|
333
|
+
disagree about. Only a host that talks to the frame can be out of date.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## 8. Debugging a URL
|
|
338
|
+
|
|
339
|
+
A parameter the viewer cannot read is **dropped and named**; it is never repaired or guessed at, and it
|
|
340
|
+
never blanks the viewer. The list arrives on the `hello` event as `problems`:
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
?float=banana&view=sideways&map=Atlantis&time=Dusk&scale=99&glove=nonsense
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
```js
|
|
347
|
+
problems: [
|
|
348
|
+
'?float=banana: expected a number in [0, 1]',
|
|
349
|
+
'?view=sideways: expected gun, hands or agent',
|
|
350
|
+
'?glove=nonsense: expected type:paintIndex[:float[:seed]]',
|
|
351
|
+
'?scale=99: expected a number in [0.25, 3] or Performance/Balanced/Native',
|
|
352
|
+
'?map=Atlantis: unknown map',
|
|
353
|
+
'?time=Dusk: expected Day or Night',
|
|
354
|
+
]
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Booleans follow one rule everywhere: **`0` is off, anything else present is on, absent means "leave it
|
|
358
|
+
alone".**
|
|
359
|
+
|
|
360
|
+
### `?help=` - ask the frame to explain itself
|
|
361
|
+
|
|
362
|
+
```
|
|
363
|
+
?help=1 ← "no item was passed", with the two ways to pass one
|
|
364
|
+
?help=bad-link ← "that inspect link did not decode"
|
|
365
|
+
?help=unknown-weapon ← "the item decoded, but its defindex is not one this build knows"
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Renders a short instruction card **instead of the viewer** - no scene, no WebGL context - and nothing
|
|
369
|
+
else. It exists because a bare `/frame` renders AK-47 | AUTOEXEC, which is the right answer for
|
|
370
|
+
somebody typing a URL by hand and the wrong one for a program that meant to name an item and did not:
|
|
371
|
+
our default weapon on screen looks like a successful render of the wrong item.
|
|
372
|
+
|
|
373
|
+
**The card clears itself** on the first `set` that carries `item.weaponType` or `item.paintIndex`, so
|
|
374
|
+
a host whose data arrives late can boot with `?help=1` and send the item when it turns up. That is
|
|
375
|
+
exactly what `@skinhub/viewer` does, and you can do it by hand for the same reason.
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## 9. Sizing, transparency and layout
|
|
380
|
+
|
|
381
|
+
**You own the box; the viewer fills it.** Size the `<iframe>` however your layout wants. There is no
|
|
382
|
+
aspect lock and no minimum.
|
|
383
|
+
|
|
384
|
+
**Resizing changes the picture, not just its scale.** The camera fits the item to the canvas aspect, so
|
|
385
|
+
the same AK is framed differently in a 3:4 card than on a full-bleed page. Animating a panel open
|
|
386
|
+
beside the frame re-frames the item; listen for `resize` if you need to know the box it landed on.
|
|
387
|
+
|
|
388
|
+
**Transparency works.** `bg=transparent` is the default: the embed paints no background of its own and
|
|
389
|
+
the canvas composites over your page. Give the `<iframe>` `background: transparent` and no border.
|
|
390
|
+
|
|
391
|
+
Two things worth knowing before you discover them:
|
|
392
|
+
|
|
393
|
+
- **Bloom outside the item's outline is additive light with no alpha.** Over a transparent background
|
|
394
|
+
there is nothing for it to add to, so the halo reads weaker than it does over a map. `spill` ships at
|
|
395
|
+
`0` in the embed for that reason.
|
|
396
|
+
- **A transparent iframe is not click-through.** The element still takes pointer events over its whole
|
|
397
|
+
rectangle. If you want your own UI on top of the viewer, put it outside the frame.
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## 10. Origins
|
|
402
|
+
|
|
403
|
+
There is no key and no allowlist. Anyone may embed the viewer.
|
|
404
|
+
|
|
405
|
+
**Inbound:** the frame acts on messages from its parent window and drops everything else, including
|
|
406
|
+
messages from sibling frames on the same page. This is a correctness check, not an authorisation one.
|
|
407
|
+
|
|
408
|
+
**Outbound:** the frame addresses its events to `*` until you send it something, then pins its replies
|
|
409
|
+
to the origin you spoke from. If you want replies pinned before you have sent anything - for a page
|
|
410
|
+
that configures the viewer entirely by URL and only listens - name your origin in the URL:
|
|
411
|
+
|
|
412
|
+
```
|
|
413
|
+
?origin=https://shop.example
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
You should still check `event.source` and `event.origin` on your side. The snippet in §6 does.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## 11. What an embed cannot do
|
|
421
|
+
|
|
422
|
+
Honest list, so none of it is discovered late.
|
|
423
|
+
|
|
424
|
+
- **Custom loading and error UI inside the frame.** Our React component takes `loading` and `fallback`
|
|
425
|
+
slots; a React element cannot cross a `postMessage` boundary. Draw your own skeleton over the iframe
|
|
426
|
+
and remove it on `ready`.
|
|
427
|
+
- **Click-through.** See §9.
|
|
428
|
+
- **Server-side rendering.** The viewer is WebGL; there is no server-rendered fallback image.
|
|
429
|
+
- **Render to an image.** There is no `capture()` yet. If you need a PNG per item, that is a separate
|
|
430
|
+
service and is not built.
|
|
431
|
+
- **Resolve an inspect link on its own.** `?i=` carries the item's *configuration* — float, seed,
|
|
432
|
+
StatTrak, name plate, stickers, charm — but not which weapon, so a raw-iframe integration still has
|
|
433
|
+
to send `?weapon=`/`?paint=` (or `?hash=`) alongside it.
|
|
434
|
+
**This limit is the frame's, not the product's:** `@skinhub/viewer` takes an `inspectLink` prop and
|
|
435
|
+
handles the whole thing, because it decodes host-side and resolves the `defindex` against its own
|
|
436
|
+
weapon table before building the URL. If you are in React, you never see this.
|
|
437
|
+
- **Show a sticker, charm, agent or collectible on its own.** The embed renders a weapon or a glove
|
|
438
|
+
today. The rest of our catalogue has pages on our site but is not yet embeddable.
|
|
439
|
+
- **Style anything.** The frame is our document. `gizmocolor` and `gizmoshadow` are the only visual
|
|
440
|
+
hooks.
|
|
441
|
+
- **Two viewers cheaply.** Each frame is its own WebGL context. If you need a grid of them, drop
|
|
442
|
+
`scale` and turn `bloom` off, and expect a browser context limit somewhere around 8-16.
|
package/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# @skinhub/viewer
|
|
2
|
+
|
|
3
|
+
The SkinHub CS2 skin viewer as a React component — any weapon, knife or glove, at any wear and seed,
|
|
4
|
+
with stickers and a charm. The 3D runs in an iframe on our origin, so installing this pulls in no
|
|
5
|
+
`three` and no assets. React is the only peer dependency.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @skinhub/viewer @skinhub/cdn
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
'use client'
|
|
15
|
+
import { SkinViewer } from '@skinhub/viewer'
|
|
16
|
+
|
|
17
|
+
export default function Page() {
|
|
18
|
+
return (
|
|
19
|
+
<SkinViewer item={{ weapon: 'weapon_ak47', paintIndex: 44, float: 0.27 }} style={{ width: 640, height: 420 }} />
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or straight from a Steam inspect link — float, seed, StatTrak, name plate, stickers and charm all come
|
|
25
|
+
out of it:
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
<SkinViewer inspectLink={item.inspectLink} style={{ width: 640, height: 420 }} />
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Two things worth knowing up front:
|
|
32
|
+
|
|
33
|
+
- **Give it a size.** It fills its container and has no intrinsic size; at 0 px you see nothing.
|
|
34
|
+
- **No `key` derived from the item.** `key={item.weapon}` remounts the iframe on every change and turns
|
|
35
|
+
a cross-fade into a full document load. It already handles that itself.
|
|
36
|
+
|
|
37
|
+
## Props
|
|
38
|
+
|
|
39
|
+
One of `item` or `inspectLink` is required. Everything else is optional.
|
|
40
|
+
|
|
41
|
+
| prop | type | default | |
|
|
42
|
+
|---|---|---|---|
|
|
43
|
+
| `item` | `{ weapon \| defindex, paintIndex, float?, seed?, statTrak?, nameTag?, legacyModel?, stickers?, charm? }` | — | the item. `paintIndex: 0` is vanilla |
|
|
44
|
+
| `inspectLink` | `string` | — | a masked Steam inspect link, instead of `item` |
|
|
45
|
+
| `origin` | `string` | `https://skinhub.gg` | where the embed is served. Read once, on mount |
|
|
46
|
+
| `view` | `'gun' \| 'hands' \| 'agent'` | `'gun'` | item alone, first-person, or held by an operator |
|
|
47
|
+
| `agent` | `{ id, pose? }` | default T | the operator, in `hands` and `agent` |
|
|
48
|
+
| `gloves` | `{ type, paintIndex, float?, seed? } \| null` | `null` | `null` is the wearer's own pair |
|
|
49
|
+
| `settings` | `{ camera?, quality?, environment?, overlays? }` | see below | shallow-merged per group |
|
|
50
|
+
| `interactions` | `{ orbit?, zoom?, dragStickers?, dragCharm? }` | orbit + zoom on | what the user may do |
|
|
51
|
+
| `editingSlot` | `number` | `-1` | which sticker slot is open; `5` is the charm |
|
|
52
|
+
| `onReady` | `() => void` | — | stopped loading — **a level, not an edge**: it fires again after every weapon or view change |
|
|
53
|
+
| `onError` | `(error) => void` | — | `no-item`, `bad-inspect-link`, `unknown-weapon`, `unreachable`, `render-failed`, `protocol-mismatch`, `bad-message` |
|
|
54
|
+
| `onChange` | `(item) => void` | — | the user dragged a sticker or the charm — **carries ids, not names**, see below |
|
|
55
|
+
| `onResize` | `({ width, height, dpr }) => void` | — | the canvas box changed |
|
|
56
|
+
| `onEditingSlotChange` | `(slot) => void` | — | |
|
|
57
|
+
| `loading` / `fallback` | `ReactNode` | — | your skeleton, and your empty state on error |
|
|
58
|
+
| `className` / `style` / `title` | | | on the wrapper |
|
|
59
|
+
| `handle` | `SkinViewerHandle` | — | from `useSkinViewer()` |
|
|
60
|
+
|
|
61
|
+
`settings.environment.background` defaults to `'transparent'`, so the canvas composites over your page.
|
|
62
|
+
`quality.bloomSpill` ships at `0` here. `MAP_NAMES` and `WEAPON_IDS` are exported as values for
|
|
63
|
+
building pickers.
|
|
64
|
+
|
|
65
|
+
Changing `float`, `seed`, `statTrak`, `nameTag`, `stickers` or `charm` updates **in place** — no
|
|
66
|
+
reload, no flicker. Changing the weapon, paint index, legacy variant or view shows a loading cover.
|
|
67
|
+
|
|
68
|
+
### Copying the item back out
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
import { toInspectLink } from '@skinhub/viewer'
|
|
72
|
+
|
|
73
|
+
<button onClick={() => navigator.clipboard.writeText(toInspectLink(item))}>Copy inspect link</button>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`fromInspectLink(link)` is the inverse, for seeding an editor from a link. `toPlacement(item)` gives
|
|
77
|
+
the decoded `@skinhub/cdn` placement if you want to write database rows instead.
|
|
78
|
+
|
|
79
|
+
### `onChange` gives you ids, not names
|
|
80
|
+
|
|
81
|
+
The embed has never seen the sticker catalogue, so the item it hands back has `stickers: [{ id: 5032,
|
|
82
|
+
… }]` and no name or image. **`setItem(changed)` will therefore blank those out of your own UI** — a
|
|
83
|
+
silent data loss in your state, with nothing to catch. Merge it into what you already hold instead:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
onChange={next =>
|
|
87
|
+
setItem(prev => ({
|
|
88
|
+
...prev,
|
|
89
|
+
float: next.float,
|
|
90
|
+
seed: next.seed,
|
|
91
|
+
// keep your own row, take the viewer's placement
|
|
92
|
+
stickers: next.stickers.map(s => ({ ...myCatalogue[s.id], ...s })),
|
|
93
|
+
charm: next.charm && { ...prev.charm, ...next.charm },
|
|
94
|
+
}))
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Pointing at your own instance
|
|
99
|
+
|
|
100
|
+
`origin` defaults to `https://skinhub.gg`. Override it to run against a mirror, a proxy, or a local
|
|
101
|
+
instance while developing:
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
<SkinViewer item={item} origin="http://localhost:3000" />
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
It is read once, at mount — it is the one prop that could only be applied by reloading the frame.
|
|
108
|
+
|
|
109
|
+
## useSkinViewer()
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
const viewer = useSkinViewer()
|
|
113
|
+
|
|
114
|
+
<SkinViewer item={item} handle={viewer} />
|
|
115
|
+
|
|
116
|
+
viewer.status // 'connecting' | 'loading' | 'ready' | 'error'
|
|
117
|
+
viewer.error // SkinViewerError | null
|
|
118
|
+
viewer.problems // anything the embed rejected about the URL we built
|
|
119
|
+
viewer.reload()
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Troubleshooting
|
|
123
|
+
|
|
124
|
+
**A CORS error fetching the catalogue.** `cdn.skinhub.gg` does not send
|
|
125
|
+
`access-control-allow-origin`, so a **browser** `fetch` for `data/skins.json` is blocked by the
|
|
126
|
+
browser before your code sees it — it surfaces as a CORS failure rather than a status, so you cannot
|
|
127
|
+
even catch it. Call `@skinhub/cdn` from your **server** (a route handler, `getServerSideProps`, an RSC)
|
|
128
|
+
and pass the rows down. The files are 4–6 MB each, so you want them server-side regardless.
|
|
129
|
+
|
|
130
|
+
**Nothing renders.** If the container is 0 px in one dimension you get an empty box; the component
|
|
131
|
+
warns about that in development. Otherwise `onError` reports `unreachable` after 15 seconds, naming the
|
|
132
|
+
URL it could not reach — that means the embed never answered, so check the origin is reachable from
|
|
133
|
+
this browser and that your page's `Content-Security-Policy` allows framing it (`frame-src`).
|
|
134
|
+
|
|
135
|
+
**`onReady` fired but the picture is still covered.** `onReady` means "stopped loading" for the current
|
|
136
|
+
identity, and it fires again after every weapon or view change. Treat it as a level, not an edge.
|
|
137
|
+
|
|
138
|
+
**`protocol-mismatch`.** This package and the embed disagree about the wire. There is no back-compat
|
|
139
|
+
window by design: update `@skinhub/viewer`, or reload the embed if it is the stale one — the error
|
|
140
|
+
message says which.
|
|
141
|
+
|
|
142
|
+
## More
|
|
143
|
+
|
|
144
|
+
- **[Quick-start app](https://github.com/SkinHubgg/skinhub-quick-start)** — a working skin picker built
|
|
145
|
+
on this and `@skinhub/cdn`, ending in an inspect link. The full example.
|
|
146
|
+
- **[EMBED.md](./EMBED.md)** — the raw `<iframe>` URL and `postMessage` contract, for stacks that are
|
|
147
|
+
not React. A PHP, Rails or plain-HTML page can interpolate an item into an `src` and be done; that
|
|
148
|
+
path is first-class, not a fallback.
|
|
149
|
+
- **[@skinhub/cdn](https://github.com/SkinHubgg/skinhub-cdn)** — the CS2 catalogue this pairs with:
|
|
150
|
+
skins, stickers, gloves, agents, and the inspect-link codec.
|
|
151
|
+
- [Development and releasing](./CONTRIBUTING.md) — how the wire is kept in step with the embed.
|
|
152
|
+
|
|
153
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SkinViewerProps } from './types.js';
|
|
2
|
+
/** Where the embed is served from. Overridable per component - see {@link SkinViewerProps.origin}. */
|
|
3
|
+
export declare const DEFAULT_ORIGIN = "https://skinhub.gg";
|
|
4
|
+
/**
|
|
5
|
+
* *** HOW LONG THE EMBED HAS TO ANNOUNCE ITSELF BEFORE WE CALL IT UNREACHABLE. ***
|
|
6
|
+
*
|
|
7
|
+
* The frame posts `hello` as soon as its script runs, so this is a document fetch plus a parse - a few
|
|
8
|
+
* hundred milliseconds on a warm connection. Fifteen seconds is therefore not a performance budget, it
|
|
9
|
+
* is the point past which "still loading" stops being a credible explanation for an empty box.
|
|
10
|
+
*
|
|
11
|
+
* *** DELIBERATELY GENEROUS, BECAUSE A FALSE POSITIVE HERE IS SELF-HEALING AND A FALSE NEGATIVE IS
|
|
12
|
+
* NOT. *** The iframe stays mounted under the `fallback`, so a slow connection that lands at sixteen
|
|
13
|
+
* seconds clears the error and renders. A timer too short would flash an error at people on bad
|
|
14
|
+
* networks; no timer at all leaves them with a blank rectangle and nothing to search for.
|
|
15
|
+
*/
|
|
16
|
+
export declare const CONNECT_TIMEOUT_MS = 15000;
|
|
17
|
+
export declare const SkinViewer: (props: SkinViewerProps) => import("react").JSX.Element;
|
|
18
|
+
//# sourceMappingURL=SkinViewer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SkinViewer.d.ts","sourceRoot":"","sources":["../src/SkinViewer.tsx"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAAmB,eAAe,EAAgB,MAAM,YAAY,CAAA;AAEhF,sGAAsG;AACtG,eAAO,MAAM,cAAc,uBAAuB,CAAA;AA4ClD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,QAAS,CAAA;AAExC,eAAO,MAAM,UAAU,GAAI,OAAO,eAAe,gCAmWhD,CAAA"}
|