@quitscope/discord-welcomecard 0.1.0 → 0.2.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/README.md +112 -102
- package/dist/index.cjs +177 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +177 -40
- package/dist/index.js.map +1 -1
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -1,102 +1,112 @@
|
|
|
1
|
-
# @quitscope/discord-welcomecard
|
|
2
|
-
|
|
3
|
-
Render Discord welcome cards as static **PNG** or animated **GIF** from one builder.
|
|
4
|
-
|
|
5
|
-

|
|
6
|
-
|
|
7
|
-
- **One API, two outputs** — the same builder renders `.toPNG()` and `.toGIF()`.
|
|
8
|
-
- **Animated GIFs** — background sheen, text fade-in,
|
|
9
|
-
- **No compile pain** — built on `@napi-rs/canvas` (prebuilt binaries, no node-gyp).
|
|
10
|
-
- **Bundled font** — cards look the same on every server.
|
|
11
|
-
- **TypeScript** — full types, ESM + CJS.
|
|
12
|
-
|
|
13
|
-
## Install
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
npm install @quitscope/discord-welcomecard
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## Usage
|
|
20
|
-
|
|
21
|
-
```ts
|
|
22
|
-
import { WelcomeCard } from '@quitscope/discord-welcomecard';
|
|
23
|
-
import { toAttachment } from '@quitscope/discord-welcomecard/discord';
|
|
24
|
-
|
|
25
|
-
const card = new WelcomeCard()
|
|
26
|
-
.setPreset('centered')
|
|
27
|
-
.setUsername('Quit')
|
|
28
|
-
.setAvatar('https://cdn.discordapp.com/avatars/.../avatar.png')
|
|
29
|
-
.setSubtitle('Welcome to the server!')
|
|
30
|
-
.setMemberCount(1234)
|
|
31
|
-
.setTheme('dark')
|
|
32
|
-
.setAnimations(['background', 'text', 'avatar']);
|
|
33
|
-
|
|
34
|
-
const png = await card.toPNG();
|
|
35
|
-
const gif = await card.toGIF();
|
|
36
|
-
|
|
37
|
-
// discord.js
|
|
38
|
-
channel.send({ files: [toAttachment(gif, 'welcome.gif')] });
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
### With discord.js events
|
|
42
|
-
|
|
43
|
-
```ts
|
|
44
|
-
client.on('guildMemberAdd', async (member) => {
|
|
45
|
-
const gif = await new WelcomeCard()
|
|
46
|
-
.setUsername(member.user.displayName)
|
|
47
|
-
.setAvatar(member.user.displayAvatarURL({ extension: 'png', size: 256 }))
|
|
48
|
-
.setSubtitle(`Welcome to ${member.guild.name}!`)
|
|
49
|
-
.setMemberCount(member.guild.memberCount)
|
|
50
|
-
.setAnimations(['text', 'avatar'])
|
|
51
|
-
.toGIF();
|
|
52
|
-
|
|
53
|
-
const channel = member.guild.systemChannel;
|
|
54
|
-
channel?.send({ files: [toAttachment(gif, 'welcome.gif')] });
|
|
55
|
-
});
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
## Presets
|
|
59
|
-
|
|
60
|
-
| `centered` (default) | `neon` |
|
|
61
|
-
| --- | --- |
|
|
62
|
-
|  |  |
|
|
63
|
-
|
|
64
|
-
| `minimal` | `hero` |
|
|
65
|
-
| --- | --- |
|
|
66
|
-
|  |  |
|
|
67
|
-
|
|
68
|
-
## Animated
|
|
69
|
-
|
|
70
|
-
`setAnimations([...])` + `toGIF()` —
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
|
77
|
-
|
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
|
86
|
-
|
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
89
|
-
| `
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
`
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
1
|
+
# @quitscope/discord-welcomecard
|
|
2
|
+
|
|
3
|
+
Render Discord welcome cards as static **PNG** or animated **GIF** from one builder.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
- **One API, two outputs** — the same builder renders `.toPNG()` and `.toGIF()`.
|
|
8
|
+
- **Animated GIFs** — background sheen, text fade-in, avatar glow/bounce, rainbow ring, slide-in. Autoplays in Discord.
|
|
9
|
+
- **No compile pain** — built on `@napi-rs/canvas` (prebuilt binaries, no node-gyp).
|
|
10
|
+
- **Bundled font** — cards look the same on every server.
|
|
11
|
+
- **TypeScript** — full types, ESM + CJS.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @quitscope/discord-welcomecard
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { WelcomeCard } from '@quitscope/discord-welcomecard';
|
|
23
|
+
import { toAttachment } from '@quitscope/discord-welcomecard/discord';
|
|
24
|
+
|
|
25
|
+
const card = new WelcomeCard()
|
|
26
|
+
.setPreset('centered')
|
|
27
|
+
.setUsername('Quit')
|
|
28
|
+
.setAvatar('https://cdn.discordapp.com/avatars/.../avatar.png')
|
|
29
|
+
.setSubtitle('Welcome to the server!')
|
|
30
|
+
.setMemberCount(1234)
|
|
31
|
+
.setTheme('dark')
|
|
32
|
+
.setAnimations(['background', 'text', 'avatar']);
|
|
33
|
+
|
|
34
|
+
const png = await card.toPNG();
|
|
35
|
+
const gif = await card.toGIF();
|
|
36
|
+
|
|
37
|
+
// discord.js
|
|
38
|
+
channel.send({ files: [toAttachment(gif, 'welcome.gif')] });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### With discord.js events
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
client.on('guildMemberAdd', async (member) => {
|
|
45
|
+
const gif = await new WelcomeCard()
|
|
46
|
+
.setUsername(member.user.displayName)
|
|
47
|
+
.setAvatar(member.user.displayAvatarURL({ extension: 'png', size: 256 }))
|
|
48
|
+
.setSubtitle(`Welcome to ${member.guild.name}!`)
|
|
49
|
+
.setMemberCount(member.guild.memberCount)
|
|
50
|
+
.setAnimations(['text', 'avatar'])
|
|
51
|
+
.toGIF();
|
|
52
|
+
|
|
53
|
+
const channel = member.guild.systemChannel;
|
|
54
|
+
channel?.send({ files: [toAttachment(gif, 'welcome.gif')] });
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Presets
|
|
59
|
+
|
|
60
|
+
| `centered` (default) | `neon` |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
|  |  |
|
|
63
|
+
|
|
64
|
+
| `minimal` | `hero` |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
|  |  |
|
|
67
|
+
|
|
68
|
+
## Animated
|
|
69
|
+
|
|
70
|
+
`setAnimations([...])` + `toGIF()` — combine any of the 6 animation types:
|
|
71
|
+
|
|
72
|
+
| Type | Effect |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `'background'` | Diagonal sheen sweeps across the card |
|
|
75
|
+
| `'text'` | Text fades in (ease-out reveal) |
|
|
76
|
+
| `'avatar'` | Avatar ring pulses with a glow |
|
|
77
|
+
| `'ring'` | Ring color cycles through the full color wheel |
|
|
78
|
+
| `'slide'` | Text slides up into position from below |
|
|
79
|
+
| `'bounce'` | Avatar bounces up and down |
|
|
80
|
+
|
|
81
|
+

|
|
82
|
+
|
|
83
|
+
## API
|
|
84
|
+
|
|
85
|
+
| Method | Description |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `setPreset(name)` | `'centered'` (default), `'neon'`, `'minimal'`, `'hero'` |
|
|
88
|
+
| `setUsername(name)` | Main text (required) |
|
|
89
|
+
| `setAvatar(urlOrBuffer)` | Avatar image; falls back to a colored circle if it fails to load |
|
|
90
|
+
| `setSubtitle(text)` | Secondary line, e.g. "Welcome to the server!" |
|
|
91
|
+
| `setMemberCount(n)` | Renders "MEMBER #n" — optional, omit to hide |
|
|
92
|
+
| `setMemberCountPosition(pos)` | 3×3 grid: `'top-left'`, `'top-center'`, `'top-right'`, `'center-left'`, `'center'`, `'center-right'`, `'bottom-left'`, `'bottom-center'`, `'bottom-right'`. `'corner'` is an alias for `'bottom-right'`. Defaults: `'bottom-center'` in the centered presets, `'bottom-right'` in `hero`. |
|
|
93
|
+
| `setBackground(value)` | Hex color (`#1e1e2e`), image URL, or `Buffer` |
|
|
94
|
+
| `setRingColor(hex)` | Override the avatar ring / glow color |
|
|
95
|
+
| `setTheme(theme)` | `'dark'` (default) or `'light'` |
|
|
96
|
+
| `setFont({ family, color, usernameColor, size, subtitleSize })` | Override font settings; `usernameColor` applies only to the username line |
|
|
97
|
+
| `setAnimations(list)` | Any of `'background'`, `'text'`, `'avatar'`, `'ring'`, `'slide'`, `'bounce'` — used by `toGIF()` |
|
|
98
|
+
| `toPNG()` | `Promise<Buffer>` — static card |
|
|
99
|
+
| `toGIF()` | `Promise<Buffer>` — animated card |
|
|
100
|
+
|
|
101
|
+
The `toAttachment(buffer, name?)` helper lives in `@quitscope/discord-welcomecard/discord` and requires
|
|
102
|
+
`discord.js` (optional peer dependency). The core package works without it.
|
|
103
|
+
|
|
104
|
+
Only `setUsername()` is required — everything else has sensible defaults or fallbacks.
|
|
105
|
+
|
|
106
|
+
## Requirements
|
|
107
|
+
|
|
108
|
+
- Node.js ≥ 18
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT. Font: [Poppins](https://fonts.google.com/specimen/Poppins) (SIL Open Font License, bundled).
|
package/dist/index.cjs
CHANGED
|
@@ -30,11 +30,11 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
|
|
|
30
30
|
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
31
31
|
|
|
32
32
|
// src/constants.ts
|
|
33
|
-
var CARD_WIDTH =
|
|
34
|
-
var CARD_HEIGHT =
|
|
33
|
+
var CARD_WIDTH = 840;
|
|
34
|
+
var CARD_HEIGHT = 420;
|
|
35
35
|
var GIF_FRAMES = 30;
|
|
36
36
|
var GIF_DELAY_MS = 50;
|
|
37
|
-
var GIF_SCALE =
|
|
37
|
+
var GIF_SCALE = 1;
|
|
38
38
|
var DEFAULT_OPTIONS = {
|
|
39
39
|
preset: "centered",
|
|
40
40
|
theme: "dark",
|
|
@@ -62,50 +62,80 @@ var DEFAULT_FONT_FAMILY = "WelcomeCard";
|
|
|
62
62
|
// src/presets/centered.ts
|
|
63
63
|
var EDGE_PAD = 24;
|
|
64
64
|
var TOP_BASELINE = 36;
|
|
65
|
+
function isHexBackground(background) {
|
|
66
|
+
return typeof background === "string" && /^#[0-9a-fA-F]{3,8}$/.test(background);
|
|
67
|
+
}
|
|
68
|
+
function resolveBackgroundColor(background, fallback) {
|
|
69
|
+
return isHexBackground(background) ? background : fallback;
|
|
70
|
+
}
|
|
65
71
|
function resolvePosition(position, width, height) {
|
|
66
72
|
const pos = position === void 0 || position === "corner" ? "bottom-right" : position;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
const x = { left: EDGE_PAD, center: width / 2, right: width - EDGE_PAD };
|
|
74
|
+
const y = { top: TOP_BASELINE, center: height / 2, bottom: height - EDGE_PAD };
|
|
75
|
+
switch (pos) {
|
|
76
|
+
case "top-left":
|
|
77
|
+
return { x: x.left, y: y.top, align: "left" };
|
|
78
|
+
case "top-center":
|
|
79
|
+
return { x: x.center, y: y.top, align: "center" };
|
|
80
|
+
case "top-right":
|
|
81
|
+
return { x: x.right, y: y.top, align: "right" };
|
|
82
|
+
case "center-left":
|
|
83
|
+
return { x: x.left, y: y.center, align: "left" };
|
|
84
|
+
case "center":
|
|
85
|
+
return { x: x.center, y: y.center, align: "center" };
|
|
86
|
+
case "center-right":
|
|
87
|
+
return { x: x.right, y: y.center, align: "right" };
|
|
88
|
+
case "bottom-left":
|
|
89
|
+
return { x: x.left, y: y.bottom, align: "left" };
|
|
90
|
+
case "bottom-center":
|
|
91
|
+
return { x: x.center, y: y.bottom, align: "center" };
|
|
92
|
+
case "bottom-right":
|
|
93
|
+
return { x: x.right, y: y.bottom, align: "right" };
|
|
94
|
+
default: {
|
|
95
|
+
const _exhausted = pos;
|
|
96
|
+
throw new Error(`Unhandled memberCountPosition: "${_exhausted}"`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
73
99
|
}
|
|
74
100
|
function centeredLayout(opts) {
|
|
75
101
|
const width = CARD_WIDTH;
|
|
76
102
|
const height = CARD_HEIGHT;
|
|
77
103
|
const accent = opts.theme === "dark" ? "#89b4fa" : "#1e66f5";
|
|
78
|
-
const bgColor =
|
|
104
|
+
const bgColor = resolveBackgroundColor(
|
|
105
|
+
opts.background,
|
|
106
|
+
opts.theme === "dark" ? "#1e1e2e" : "#eff1f5"
|
|
107
|
+
);
|
|
79
108
|
const textColor = opts.font.color ?? (opts.theme === "dark" ? "#ffffff" : "#11111b");
|
|
109
|
+
const usernameColor = opts.font.usernameColor ?? textColor;
|
|
80
110
|
const family = opts.font.family ?? DEFAULT_FONT_FAMILY;
|
|
81
|
-
const avatarSize =
|
|
82
|
-
const avatarY =
|
|
111
|
+
const avatarSize = 170;
|
|
112
|
+
const avatarY = 56;
|
|
83
113
|
return {
|
|
84
114
|
width,
|
|
85
115
|
height,
|
|
86
116
|
backgroundColor: bgColor,
|
|
87
|
-
background:
|
|
117
|
+
background: isHexBackground(opts.background) ? void 0 : opts.background,
|
|
88
118
|
avatar: {
|
|
89
119
|
x: width / 2 - avatarSize / 2,
|
|
90
120
|
y: avatarY,
|
|
91
121
|
size: avatarSize,
|
|
92
|
-
ringColor: accent,
|
|
122
|
+
ringColor: opts.ringColor ?? accent,
|
|
93
123
|
source: opts.avatar
|
|
94
124
|
},
|
|
95
125
|
username: {
|
|
96
126
|
text: opts.username ?? "",
|
|
97
127
|
x: width / 2,
|
|
98
|
-
y: avatarY + avatarSize +
|
|
99
|
-
size: opts.font.size ??
|
|
100
|
-
color:
|
|
128
|
+
y: avatarY + avatarSize + 66,
|
|
129
|
+
size: opts.font.size ?? 56,
|
|
130
|
+
color: usernameColor,
|
|
101
131
|
family,
|
|
102
132
|
align: "center"
|
|
103
133
|
},
|
|
104
134
|
subtitle: opts.subtitle ? {
|
|
105
135
|
text: opts.subtitle,
|
|
106
136
|
x: width / 2,
|
|
107
|
-
y: avatarY + avatarSize +
|
|
108
|
-
size:
|
|
137
|
+
y: avatarY + avatarSize + 122,
|
|
138
|
+
size: opts.font.subtitleSize ?? Math.round((opts.font.size ?? 56) * 0.61),
|
|
109
139
|
color: textColor,
|
|
110
140
|
family,
|
|
111
141
|
align: "center"
|
|
@@ -114,7 +144,7 @@ function centeredLayout(opts) {
|
|
|
114
144
|
text: `MEMBER #${opts.memberCount}`,
|
|
115
145
|
// centered preset: everything centered by default, count included
|
|
116
146
|
...resolvePosition(opts.memberCountPosition ?? "bottom-center", width, height),
|
|
117
|
-
size:
|
|
147
|
+
size: 20,
|
|
118
148
|
color: textColor,
|
|
119
149
|
family
|
|
120
150
|
} : void 0
|
|
@@ -126,7 +156,7 @@ function neonLayout(opts) {
|
|
|
126
156
|
const base = centeredLayout(opts);
|
|
127
157
|
return {
|
|
128
158
|
...base,
|
|
129
|
-
backgroundColor:
|
|
159
|
+
backgroundColor: resolveBackgroundColor(opts.background, "#0b0b1a"),
|
|
130
160
|
avatar: { ...base.avatar, ringColor: "#00ffd5" },
|
|
131
161
|
username: { ...base.username, color: opts.font.color ?? "#00ffd5" }
|
|
132
162
|
};
|
|
@@ -137,7 +167,7 @@ function minimalLayout(opts) {
|
|
|
137
167
|
const base = centeredLayout(opts);
|
|
138
168
|
return {
|
|
139
169
|
...base,
|
|
140
|
-
backgroundColor:
|
|
170
|
+
backgroundColor: resolveBackgroundColor(opts.background, "#ffffff"),
|
|
141
171
|
avatar: { ...base.avatar, ringColor: "#222222" },
|
|
142
172
|
username: { ...base.username, color: opts.font.color ?? "#111111" },
|
|
143
173
|
subtitle: base.subtitle ? { ...base.subtitle, color: "#555555" } : void 0
|
|
@@ -151,10 +181,13 @@ function heroLayout(opts) {
|
|
|
151
181
|
const textX = avatarX + base.avatar.size + 40;
|
|
152
182
|
return {
|
|
153
183
|
...base,
|
|
154
|
-
backgroundColor:
|
|
184
|
+
backgroundColor: resolveBackgroundColor(
|
|
185
|
+
opts.background,
|
|
186
|
+
opts.theme === "dark" ? "#1b1f2a" : "#e8ecf4"
|
|
187
|
+
),
|
|
155
188
|
avatar: { ...base.avatar, x: avatarX, y: base.height / 2 - base.avatar.size / 2 },
|
|
156
|
-
username: { ...base.username, x: textX, y: base.height / 2 -
|
|
157
|
-
subtitle: base.subtitle ? { ...base.subtitle, x: textX, y: base.height / 2 +
|
|
189
|
+
username: { ...base.username, x: textX, y: base.height / 2 - 12, align: "left" },
|
|
190
|
+
subtitle: base.subtitle ? { ...base.subtitle, x: textX, y: base.height / 2 + 42, align: "left" } : void 0,
|
|
158
191
|
memberCount: base.memberCount ? (
|
|
159
192
|
// hero is asymmetric — default the count to the corner, not bottom-center
|
|
160
193
|
{
|
|
@@ -197,37 +230,109 @@ function layout(opts) {
|
|
|
197
230
|
|
|
198
231
|
// src/assets/loadImage.ts
|
|
199
232
|
var import_canvas2 = require("@napi-rs/canvas");
|
|
233
|
+
var MAX_CACHE = 256;
|
|
234
|
+
function boundedSet(map, key, value) {
|
|
235
|
+
if (map.size >= MAX_CACHE) {
|
|
236
|
+
map.delete(map.keys().next().value);
|
|
237
|
+
}
|
|
238
|
+
map.set(key, value);
|
|
239
|
+
}
|
|
240
|
+
var cache = /* @__PURE__ */ new Map();
|
|
241
|
+
var solidCache = /* @__PURE__ */ new Map();
|
|
200
242
|
async function solidImage(color, w, h) {
|
|
243
|
+
const key = `${color}:${w}x${h}`;
|
|
244
|
+
const hit = solidCache.get(key);
|
|
245
|
+
if (hit) return hit;
|
|
201
246
|
const canvas = (0, import_canvas2.createCanvas)(w, h);
|
|
202
247
|
const ctx = canvas.getContext("2d");
|
|
203
248
|
ctx.fillStyle = color;
|
|
204
249
|
ctx.fillRect(0, 0, w, h);
|
|
205
|
-
|
|
250
|
+
const img = await (0, import_canvas2.loadImage)(canvas.toBuffer("image/png"));
|
|
251
|
+
boundedSet(solidCache, key, img);
|
|
252
|
+
return img;
|
|
206
253
|
}
|
|
207
|
-
var cache = /* @__PURE__ */ new Map();
|
|
208
254
|
async function loadImageOrFallback(source, fallbackColor, w = 256, h = 256) {
|
|
209
255
|
if (source === void 0) return solidImage(fallbackColor, w, h);
|
|
210
256
|
const hit = cache.get(source);
|
|
211
257
|
if (hit) return hit;
|
|
212
258
|
try {
|
|
213
259
|
const img = await (0, import_canvas2.loadImage)(source);
|
|
214
|
-
cache
|
|
260
|
+
boundedSet(cache, source, img);
|
|
215
261
|
return img;
|
|
216
262
|
} catch {
|
|
217
263
|
return solidImage(fallbackColor, w, h);
|
|
218
264
|
}
|
|
219
265
|
}
|
|
220
266
|
|
|
267
|
+
// src/assets/colorUtils.ts
|
|
268
|
+
function hexToHsl(hex) {
|
|
269
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
270
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
271
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
272
|
+
const max = Math.max(r, g, b);
|
|
273
|
+
const min = Math.min(r, g, b);
|
|
274
|
+
const l = (max + min) / 2;
|
|
275
|
+
if (max === min) return [0, 0, l];
|
|
276
|
+
const d = max - min;
|
|
277
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
278
|
+
let h;
|
|
279
|
+
switch (max) {
|
|
280
|
+
case r:
|
|
281
|
+
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
282
|
+
break;
|
|
283
|
+
case g:
|
|
284
|
+
h = ((b - r) / d + 2) / 6;
|
|
285
|
+
break;
|
|
286
|
+
default:
|
|
287
|
+
h = ((r - g) / d + 4) / 6;
|
|
288
|
+
}
|
|
289
|
+
return [h, s, l];
|
|
290
|
+
}
|
|
291
|
+
function hslToHex(h, s, l) {
|
|
292
|
+
const hue2rgb = (p, q2, t) => {
|
|
293
|
+
if (t < 0) t += 1;
|
|
294
|
+
if (t > 1) t -= 1;
|
|
295
|
+
if (t < 1 / 6) return p + (q2 - p) * 6 * t;
|
|
296
|
+
if (t < 1 / 2) return q2;
|
|
297
|
+
if (t < 2 / 3) return p + (q2 - p) * (2 / 3 - t) * 6;
|
|
298
|
+
return p;
|
|
299
|
+
};
|
|
300
|
+
let r, g, b;
|
|
301
|
+
if (s === 0) {
|
|
302
|
+
r = g = b = l;
|
|
303
|
+
} else {
|
|
304
|
+
const q2 = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
305
|
+
const p = 2 * l - q2;
|
|
306
|
+
r = hue2rgb(p, q2, h + 1 / 3);
|
|
307
|
+
g = hue2rgb(p, q2, h);
|
|
308
|
+
b = hue2rgb(p, q2, h - 1 / 3);
|
|
309
|
+
}
|
|
310
|
+
const hex = (x) => Math.round(x * 255).toString(16).padStart(2, "0");
|
|
311
|
+
return `#${hex(r)}${hex(g)}${hex(b)}`;
|
|
312
|
+
}
|
|
313
|
+
function rotateHue(hexColor, amount) {
|
|
314
|
+
const [h, s, l] = hexToHsl(hexColor);
|
|
315
|
+
return hslToHex((h + amount + 1) % 1, s, l);
|
|
316
|
+
}
|
|
317
|
+
|
|
221
318
|
// src/render/drawFrame.ts
|
|
222
|
-
var STATIC_FRAME = {
|
|
223
|
-
|
|
319
|
+
var STATIC_FRAME = {
|
|
320
|
+
textAlpha: 1,
|
|
321
|
+
avatarGlow: 1,
|
|
322
|
+
backgroundShift: 0,
|
|
323
|
+
ringShift: 0,
|
|
324
|
+
textSlide: 1,
|
|
325
|
+
avatarBounce: 0
|
|
326
|
+
};
|
|
327
|
+
var BOUNCE_AMPLITUDE = 10;
|
|
328
|
+
function drawText(ctx, box, alpha, slideOffset) {
|
|
224
329
|
ctx.save();
|
|
225
330
|
ctx.globalAlpha = alpha;
|
|
226
331
|
ctx.fillStyle = box.color;
|
|
227
332
|
ctx.textAlign = box.align;
|
|
228
333
|
ctx.textBaseline = "alphabetic";
|
|
229
334
|
ctx.font = `${box.size}px ${box.family}`;
|
|
230
|
-
ctx.fillText(box.text, box.x, box.y);
|
|
335
|
+
ctx.fillText(box.text, box.x, box.y + slideOffset);
|
|
231
336
|
ctx.restore();
|
|
232
337
|
}
|
|
233
338
|
async function drawFrame(ctx, l, state) {
|
|
@@ -247,13 +352,15 @@ async function drawFrame(ctx, l, state) {
|
|
|
247
352
|
ctx.fillRect(0, 0, l.width, l.height);
|
|
248
353
|
}
|
|
249
354
|
const { x, y, size, ringColor } = l.avatar;
|
|
355
|
+
const animatedRingColor = state.ringShift > 0 ? rotateHue(ringColor, state.ringShift) : ringColor;
|
|
356
|
+
const bounceOffset = state.avatarBounce * BOUNCE_AMPLITUDE;
|
|
250
357
|
const cx = x + size / 2;
|
|
251
|
-
const cy = y + size / 2;
|
|
358
|
+
const cy = y + size / 2 + bounceOffset;
|
|
252
359
|
ctx.save();
|
|
253
|
-
ctx.shadowColor =
|
|
360
|
+
ctx.shadowColor = animatedRingColor;
|
|
254
361
|
ctx.shadowBlur = 10 + state.avatarGlow * 25;
|
|
255
362
|
ctx.lineWidth = 6;
|
|
256
|
-
ctx.strokeStyle =
|
|
363
|
+
ctx.strokeStyle = animatedRingColor;
|
|
257
364
|
ctx.beginPath();
|
|
258
365
|
ctx.arc(cx, cy, size / 2 + 3, 0, Math.PI * 2);
|
|
259
366
|
ctx.stroke();
|
|
@@ -263,11 +370,12 @@ async function drawFrame(ctx, l, state) {
|
|
|
263
370
|
ctx.beginPath();
|
|
264
371
|
ctx.arc(cx, cy, size / 2, 0, Math.PI * 2);
|
|
265
372
|
ctx.clip();
|
|
266
|
-
ctx.drawImage(avatarImg, x, y, size, size);
|
|
373
|
+
ctx.drawImage(avatarImg, x, y + bounceOffset, size, size);
|
|
267
374
|
ctx.restore();
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (l.
|
|
375
|
+
const slideOffset = (1 - state.textSlide) * l.height;
|
|
376
|
+
drawText(ctx, l.username, state.textAlpha, slideOffset);
|
|
377
|
+
if (l.subtitle) drawText(ctx, l.subtitle, state.textAlpha, slideOffset);
|
|
378
|
+
if (l.memberCount) drawText(ctx, l.memberCount, state.textAlpha, slideOffset);
|
|
271
379
|
}
|
|
272
380
|
|
|
273
381
|
// src/render/toPNG.ts
|
|
@@ -646,12 +754,31 @@ function backgroundShift(progress) {
|
|
|
646
754
|
return progress;
|
|
647
755
|
}
|
|
648
756
|
|
|
757
|
+
// src/animate/ring.ts
|
|
758
|
+
function ringShift(progress) {
|
|
759
|
+
return progress;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// src/animate/slide.ts
|
|
763
|
+
function textSlide(progress) {
|
|
764
|
+
const p = Math.min(progress / 0.8, 1);
|
|
765
|
+
return p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// src/animate/bounce.ts
|
|
769
|
+
function avatarBounce(progress) {
|
|
770
|
+
return Math.sin(progress * Math.PI * 2);
|
|
771
|
+
}
|
|
772
|
+
|
|
649
773
|
// src/animate/index.ts
|
|
650
774
|
function frameStateFor(animations, progress) {
|
|
651
775
|
return {
|
|
652
776
|
textAlpha: animations.includes("text") ? textAlpha(progress) : 1,
|
|
653
777
|
avatarGlow: animations.includes("avatar") ? avatarGlow(progress) : 1,
|
|
654
|
-
backgroundShift: animations.includes("background") ? backgroundShift(progress) : 0
|
|
778
|
+
backgroundShift: animations.includes("background") ? backgroundShift(progress) : 0,
|
|
779
|
+
ringShift: animations.includes("ring") ? ringShift(progress) : 0,
|
|
780
|
+
textSlide: animations.includes("slide") ? textSlide(progress) : 1,
|
|
781
|
+
avatarBounce: animations.includes("bounce") ? avatarBounce(progress) : 0
|
|
655
782
|
};
|
|
656
783
|
}
|
|
657
784
|
|
|
@@ -668,6 +795,12 @@ async function renderGIF(opts) {
|
|
|
668
795
|
const enc = GIFEncoder();
|
|
669
796
|
const frames = opts.animations.length === 0 ? 1 : GIF_FRAMES;
|
|
670
797
|
let palette;
|
|
798
|
+
if (frames > 1) {
|
|
799
|
+
const paletteState = frameStateFor(opts.animations, 0.5);
|
|
800
|
+
await drawFrame(ctx, l, paletteState);
|
|
801
|
+
outCtx.drawImage(canvas, 0, 0, gw, gh);
|
|
802
|
+
palette = quantize(outCtx.getImageData(0, 0, gw, gh).data, 256);
|
|
803
|
+
}
|
|
671
804
|
for (let i = 0; i < frames; i++) {
|
|
672
805
|
const progress = frames === 1 ? 0 : i / frames;
|
|
673
806
|
const state = frameStateFor(opts.animations, progress);
|
|
@@ -684,7 +817,7 @@ async function renderGIF(opts) {
|
|
|
684
817
|
|
|
685
818
|
// src/WelcomeCard.ts
|
|
686
819
|
var WelcomeCard = class {
|
|
687
|
-
opts = { ...DEFAULT_OPTIONS
|
|
820
|
+
opts = { ...DEFAULT_OPTIONS };
|
|
688
821
|
setPreset(preset) {
|
|
689
822
|
if (!PRESETS[preset]) {
|
|
690
823
|
throw new WelcomeCardError(
|
|
@@ -718,6 +851,10 @@ var WelcomeCard = class {
|
|
|
718
851
|
this.opts.background = background;
|
|
719
852
|
return this;
|
|
720
853
|
}
|
|
854
|
+
setRingColor(color) {
|
|
855
|
+
this.opts.ringColor = color;
|
|
856
|
+
return this;
|
|
857
|
+
}
|
|
721
858
|
setTheme(theme) {
|
|
722
859
|
this.opts.theme = theme;
|
|
723
860
|
return this;
|