@sayknow-cli/tui 0.6.9 → 0.7.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/CHANGELOG.md +9 -0
- package/dist/types/components/sayknow-pet.d.ts +9 -47
- package/dist/types/components/sayo-placeholder.d.ts +23 -0
- package/dist/types/components/sayo-sprite-source.d.ts +6 -0
- package/dist/types/components/sayo-sprite.d.ts +13 -0
- package/dist/types/index.d.ts +2 -0
- package/package.json +3 -3
- package/src/components/sayknow-pet.ts +94 -25
- package/src/components/sayo-placeholder.ts +102 -0
- package/src/components/sayo-sprite-source.ts +459 -0
- package/src/components/sayo-sprite.ts +389 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sayo sprite: the pet drawn from the Sayo mascot image instead of the 16×16 pixel grid,
|
|
3
|
+
* for terminals with the kitty graphics protocol (Ghostty, kitty, WezTerm).
|
|
4
|
+
*
|
|
5
|
+
* Every pose is one image transformed: a tilt about the tentacle line (dance, glance),
|
|
6
|
+
* squash and stretch (the "yay" beat), eyelids painted over the eyes (blink) and tears
|
|
7
|
+
* below them (the sob). Colors are hue shifts of the orange original, so red and blue
|
|
8
|
+
* keep the same shading. Nothing here touches the network or the file system; the source
|
|
9
|
+
* PNG is embedded and decoded once.
|
|
10
|
+
*/
|
|
11
|
+
import { inflateSync } from "node:zlib";
|
|
12
|
+
import { SAYO_SOURCE_PNG_BASE64 } from "./sayo-sprite-source";
|
|
13
|
+
|
|
14
|
+
export type SayoPose = "base" | "gazeL" | "gazeR" | "flicker" | "flex" | "danceL" | "danceR" | "cry1" | "cry2" | "cry3";
|
|
15
|
+
|
|
16
|
+
export type SayoTint = "orange" | "red" | "blue";
|
|
17
|
+
|
|
18
|
+
/** Straight (non-premultiplied) RGBA pixels. */
|
|
19
|
+
export interface RgbaImage {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
data: Uint8Array;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── PNG decoding (8-bit RGB/RGBA, non-interlaced: what Bun.Image writes) ──────────────
|
|
26
|
+
|
|
27
|
+
function paeth(a: number, b: number, c: number): number {
|
|
28
|
+
const p = a + b - c;
|
|
29
|
+
const pa = Math.abs(p - a);
|
|
30
|
+
const pb = Math.abs(p - b);
|
|
31
|
+
const pc = Math.abs(p - c);
|
|
32
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function decodePng(bytes: Uint8Array): RgbaImage {
|
|
36
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
37
|
+
let offset = 8;
|
|
38
|
+
let width = 0;
|
|
39
|
+
let height = 0;
|
|
40
|
+
let colorType = 0;
|
|
41
|
+
const idat: Uint8Array[] = [];
|
|
42
|
+
while (offset < bytes.length) {
|
|
43
|
+
const length = view.getUint32(offset);
|
|
44
|
+
const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
|
|
45
|
+
const body = bytes.subarray(offset + 8, offset + 8 + length);
|
|
46
|
+
if (type === "IHDR") {
|
|
47
|
+
width = view.getUint32(offset + 8);
|
|
48
|
+
height = view.getUint32(offset + 12);
|
|
49
|
+
const depth = body[8];
|
|
50
|
+
colorType = body[9]!;
|
|
51
|
+
if (depth !== 8 || (colorType !== 6 && colorType !== 2) || body[12] !== 0) {
|
|
52
|
+
throw new Error(`Unsupported PNG (depth ${depth}, color type ${colorType}, interlace ${body[12]})`);
|
|
53
|
+
}
|
|
54
|
+
} else if (type === "IDAT") idat.push(body);
|
|
55
|
+
else if (type === "IEND") break;
|
|
56
|
+
offset += 12 + length;
|
|
57
|
+
}
|
|
58
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
59
|
+
const channels = colorType === 6 ? 4 : 3;
|
|
60
|
+
const stride = width * channels;
|
|
61
|
+
const pixels = new Uint8Array(width * height * channels);
|
|
62
|
+
for (let y = 0; y < height; y++) {
|
|
63
|
+
const filter = raw[y * (stride + 1)]!;
|
|
64
|
+
const src = y * (stride + 1) + 1;
|
|
65
|
+
const dst = y * stride;
|
|
66
|
+
for (let x = 0; x < stride; x++) {
|
|
67
|
+
const value = raw[src + x]!;
|
|
68
|
+
const left = x >= channels ? pixels[dst + x - channels]! : 0;
|
|
69
|
+
const up = y > 0 ? pixels[dst - stride + x]! : 0;
|
|
70
|
+
const upLeft = y > 0 && x >= channels ? pixels[dst - stride + x - channels]! : 0;
|
|
71
|
+
pixels[dst + x] =
|
|
72
|
+
(filter === 0
|
|
73
|
+
? value
|
|
74
|
+
: filter === 1
|
|
75
|
+
? value + left
|
|
76
|
+
: filter === 2
|
|
77
|
+
? value + up
|
|
78
|
+
: filter === 3
|
|
79
|
+
? value + ((left + up) >> 1)
|
|
80
|
+
: value + paeth(left, up, upLeft)) & 0xff;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (channels === 4) return { width, height, data: pixels };
|
|
84
|
+
const data = new Uint8Array(width * height * 4);
|
|
85
|
+
for (let i = 0, j = 0; i < pixels.length; i += 3, j += 4) {
|
|
86
|
+
data[j] = pixels[i]!;
|
|
87
|
+
data[j + 1] = pixels[i + 1]!;
|
|
88
|
+
data[j + 2] = pixels[i + 2]!;
|
|
89
|
+
data[j + 3] = 255;
|
|
90
|
+
}
|
|
91
|
+
return { width, height, data };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Source: decoded once, cropped to the octopus ─────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
interface SayoSource {
|
|
97
|
+
/** Premultiplied RGBA floats of the cropped octopus. */
|
|
98
|
+
pixels: Float32Array;
|
|
99
|
+
width: number;
|
|
100
|
+
height: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let cachedSource: SayoSource | undefined;
|
|
104
|
+
|
|
105
|
+
function sayoSource(): SayoSource {
|
|
106
|
+
if (cachedSource) return cachedSource;
|
|
107
|
+
const image = decodePng(Buffer.from(SAYO_SOURCE_PNG_BASE64, "base64"));
|
|
108
|
+
let minX = image.width;
|
|
109
|
+
let minY = image.height;
|
|
110
|
+
let maxX = -1;
|
|
111
|
+
let maxY = -1;
|
|
112
|
+
for (let y = 0; y < image.height; y++) {
|
|
113
|
+
for (let x = 0; x < image.width; x++) {
|
|
114
|
+
if (image.data[(y * image.width + x) * 4 + 3]! < 24) continue;
|
|
115
|
+
if (x < minX) minX = x;
|
|
116
|
+
if (x > maxX) maxX = x;
|
|
117
|
+
if (y < minY) minY = y;
|
|
118
|
+
if (y > maxY) maxY = y;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Square crop around the octopus, so every pose shares one box.
|
|
122
|
+
const side = Math.max(maxX - minX + 1, maxY - minY + 1);
|
|
123
|
+
const originX = Math.round((minX + maxX + 1 - side) / 2);
|
|
124
|
+
const originY = Math.round((minY + maxY + 1 - side) / 2);
|
|
125
|
+
const pixels = new Float32Array(side * side * 4);
|
|
126
|
+
for (let y = 0; y < side; y++) {
|
|
127
|
+
for (let x = 0; x < side; x++) {
|
|
128
|
+
const sx = originX + x;
|
|
129
|
+
const sy = originY + y;
|
|
130
|
+
if (sx < 0 || sy < 0 || sx >= image.width || sy >= image.height) continue;
|
|
131
|
+
const i = (sy * image.width + sx) * 4;
|
|
132
|
+
const alpha = image.data[i + 3]! / 255;
|
|
133
|
+
const o = (y * side + x) * 4;
|
|
134
|
+
pixels[o] = (image.data[i]! / 255) * alpha;
|
|
135
|
+
pixels[o + 1] = (image.data[i + 1]! / 255) * alpha;
|
|
136
|
+
pixels[o + 2] = (image.data[i + 2]! / 255) * alpha;
|
|
137
|
+
pixels[o + 3] = alpha;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
cachedSource = { pixels, width: side, height: side };
|
|
141
|
+
return cachedSource;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Area-average downscale (premultiplied), so small sizes stay smooth instead of aliased. */
|
|
145
|
+
function downscale(source: SayoSource, size: number): SayoSource {
|
|
146
|
+
const out = new Float32Array(size * size * 4);
|
|
147
|
+
const ratio = source.width / size;
|
|
148
|
+
for (let y = 0; y < size; y++) {
|
|
149
|
+
const y0 = y * ratio;
|
|
150
|
+
const y1 = (y + 1) * ratio;
|
|
151
|
+
for (let x = 0; x < size; x++) {
|
|
152
|
+
const x0 = x * ratio;
|
|
153
|
+
const x1 = (x + 1) * ratio;
|
|
154
|
+
let r = 0;
|
|
155
|
+
let g = 0;
|
|
156
|
+
let b = 0;
|
|
157
|
+
let a = 0;
|
|
158
|
+
let weight = 0;
|
|
159
|
+
for (let sy = Math.floor(y0); sy < Math.ceil(y1); sy++) {
|
|
160
|
+
const wy = Math.min(y1, sy + 1) - Math.max(y0, sy);
|
|
161
|
+
for (let sx = Math.floor(x0); sx < Math.ceil(x1); sx++) {
|
|
162
|
+
const w = wy * (Math.min(x1, sx + 1) - Math.max(x0, sx));
|
|
163
|
+
const i = (sy * source.width + sx) * 4;
|
|
164
|
+
r += source.pixels[i]! * w;
|
|
165
|
+
g += source.pixels[i + 1]! * w;
|
|
166
|
+
b += source.pixels[i + 2]! * w;
|
|
167
|
+
a += source.pixels[i + 3]! * w;
|
|
168
|
+
weight += w;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const o = (y * size + x) * 4;
|
|
172
|
+
out[o] = r / weight;
|
|
173
|
+
out[o + 1] = g / weight;
|
|
174
|
+
out[o + 2] = b / weight;
|
|
175
|
+
out[o + 3] = a / weight;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { pixels: out, width: size, height: size };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sampleBilinear(source: SayoSource, x: number, y: number, out: Float32Array): void {
|
|
182
|
+
const fx = x - 0.5;
|
|
183
|
+
const fy = y - 0.5;
|
|
184
|
+
const x0 = Math.floor(fx);
|
|
185
|
+
const y0 = Math.floor(fy);
|
|
186
|
+
const tx = fx - x0;
|
|
187
|
+
const ty = fy - y0;
|
|
188
|
+
out.fill(0);
|
|
189
|
+
for (const [dx, dy, w] of [
|
|
190
|
+
[0, 0, (1 - tx) * (1 - ty)],
|
|
191
|
+
[1, 0, tx * (1 - ty)],
|
|
192
|
+
[0, 1, (1 - tx) * ty],
|
|
193
|
+
[1, 1, tx * ty],
|
|
194
|
+
] as const) {
|
|
195
|
+
const sx = x0 + dx;
|
|
196
|
+
const sy = y0 + dy;
|
|
197
|
+
if (w === 0 || sx < 0 || sy < 0 || sx >= source.width || sy >= source.height) continue;
|
|
198
|
+
const i = (sy * source.width + sx) * 4;
|
|
199
|
+
out[0] += source.pixels[i]! * w;
|
|
200
|
+
out[1] += source.pixels[i + 1]! * w;
|
|
201
|
+
out[2] += source.pixels[i + 2]! * w;
|
|
202
|
+
out[3] += source.pixels[i + 3]! * w;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Poses ────────────────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Eye centers and radius in the cropped source, as fractions of its side (measured on
|
|
210
|
+
* sayo-logo.png: glossy black eyes either side of the smile).
|
|
211
|
+
*/
|
|
212
|
+
const EYES = [
|
|
213
|
+
{ x: 0.297, y: 0.438 },
|
|
214
|
+
{ x: 0.644, y: 0.441 },
|
|
215
|
+
] as const;
|
|
216
|
+
/** Half the measured eye height (0.13 of the side), so a lid covers the whole glossy eye. */
|
|
217
|
+
const EYE_RADIUS = 0.066;
|
|
218
|
+
|
|
219
|
+
interface PoseTransform {
|
|
220
|
+
/** Tilt in degrees about the tentacle line (positive leans right). */
|
|
221
|
+
tilt: number;
|
|
222
|
+
scaleX: number;
|
|
223
|
+
scaleY: number;
|
|
224
|
+
/** Horizontal shift as a fraction of the box. */
|
|
225
|
+
shiftX: number;
|
|
226
|
+
blink: boolean;
|
|
227
|
+
/** Pupil glance: -1 left, +1 right. */
|
|
228
|
+
glance: number;
|
|
229
|
+
/** Tear progress 1..3, 0 for none. */
|
|
230
|
+
tear: number;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const POSES: Record<SayoPose, PoseTransform> = {
|
|
234
|
+
base: { tilt: 0, scaleX: 1, scaleY: 1, shiftX: 0, blink: false, glance: 0, tear: 0 },
|
|
235
|
+
gazeL: { tilt: -3, scaleX: 1, scaleY: 1, shiftX: 0, blink: false, glance: -1, tear: 0 },
|
|
236
|
+
gazeR: { tilt: 3, scaleX: 1, scaleY: 1, shiftX: 0, blink: false, glance: 1, tear: 0 },
|
|
237
|
+
flicker: { tilt: 0, scaleX: 1, scaleY: 1, shiftX: 0, blink: true, glance: 0, tear: 0 },
|
|
238
|
+
flex: { tilt: 0, scaleX: 1.07, scaleY: 0.92, shiftX: 0, blink: false, glance: 0, tear: 0 },
|
|
239
|
+
danceL: { tilt: -9, scaleX: 1, scaleY: 1.02, shiftX: -0.04, blink: false, glance: 0, tear: 0 },
|
|
240
|
+
danceR: { tilt: 9, scaleX: 1, scaleY: 1.02, shiftX: 0.04, blink: false, glance: 0, tear: 0 },
|
|
241
|
+
cry1: { tilt: 0, scaleX: 1, scaleY: 1, shiftX: 0, blink: true, glance: 0, tear: 1 },
|
|
242
|
+
cry2: { tilt: 0, scaleX: 1, scaleY: 1, shiftX: 0, blink: true, glance: 0, tear: 2 },
|
|
243
|
+
cry3: { tilt: 0, scaleX: 1, scaleY: 1, shiftX: 0, blink: true, glance: 0, tear: 3 },
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** The box keeps a margin so tilted and squashed poses are not clipped. */
|
|
247
|
+
const POSE_MARGIN = 0.07;
|
|
248
|
+
/** Pivot for tilt and squash: the tentacle line, near the bottom of the octopus. */
|
|
249
|
+
const PIVOT_Y = 0.86;
|
|
250
|
+
|
|
251
|
+
const TEAR_RGB = [0.72, 0.9, 1] as const;
|
|
252
|
+
const LID_DARK = [0.13, 0.08, 0.07] as const;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Map a pixel of the pose into source coordinates (fractions of the source side).
|
|
256
|
+
* Returns the inverse of: shift ∘ tilt ∘ squash, all about the pivot.
|
|
257
|
+
*/
|
|
258
|
+
function inversePose(pose: PoseTransform, u: number, v: number): [number, number] {
|
|
259
|
+
// Box → unmargined sprite space.
|
|
260
|
+
let x = (u - POSE_MARGIN) / (1 - 2 * POSE_MARGIN) - pose.shiftX;
|
|
261
|
+
let y = (v - POSE_MARGIN) / (1 - 2 * POSE_MARGIN);
|
|
262
|
+
const px = 0.5;
|
|
263
|
+
const py = PIVOT_Y;
|
|
264
|
+
const angle = (-pose.tilt * Math.PI) / 180;
|
|
265
|
+
const cos = Math.cos(angle);
|
|
266
|
+
const sin = Math.sin(angle);
|
|
267
|
+
const rx = (x - px) * cos - (y - py) * sin;
|
|
268
|
+
const ry = (x - px) * sin + (y - py) * cos;
|
|
269
|
+
x = px + rx / pose.scaleX;
|
|
270
|
+
y = py + ry / pose.scaleY;
|
|
271
|
+
return [x, y];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Render one pose, tinted, into a `size`×`size` straight-RGBA image. */
|
|
275
|
+
export function renderSayoPose(pose: SayoPose, tint: SayoTint, size: number): RgbaImage {
|
|
276
|
+
const transform = POSES[pose];
|
|
277
|
+
// Work at up to 3× and area-average down: smooth edges, crisp eyes. Past the source's
|
|
278
|
+
// own resolution supersampling adds nothing but time.
|
|
279
|
+
const sourceSide = sayoSource().width;
|
|
280
|
+
const work = Math.max(8, Math.min(size * 3, Math.max(size, Math.ceil(sourceSide / (1 - 2 * POSE_MARGIN)))));
|
|
281
|
+
const source = downscale(sayoSource(), Math.min(sourceSide, Math.round(work * (1 - 2 * POSE_MARGIN))));
|
|
282
|
+
const side = source.width;
|
|
283
|
+
const sample = new Float32Array(4);
|
|
284
|
+
const bodyAbove = new Float32Array(4);
|
|
285
|
+
const canvas = new Float32Array(work * work * 4);
|
|
286
|
+
for (let y = 0; y < work; y++) {
|
|
287
|
+
for (let x = 0; x < work; x++) {
|
|
288
|
+
const [su, sv] = inversePose(transform, (x + 0.5) / work, (y + 0.5) / work);
|
|
289
|
+
let lx = su;
|
|
290
|
+
const ly = sv;
|
|
291
|
+
let eyeDistance = Number.POSITIVE_INFINITY;
|
|
292
|
+
let eyeIndex = -1;
|
|
293
|
+
for (let e = 0; e < EYES.length; e++) {
|
|
294
|
+
const d = Math.hypot((su - EYES[e]!.x) / EYE_RADIUS, (sv - EYES[e]!.y) / EYE_RADIUS);
|
|
295
|
+
if (d < eyeDistance) {
|
|
296
|
+
eyeDistance = d;
|
|
297
|
+
eyeIndex = e;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const eye = EYES[eyeIndex]!;
|
|
301
|
+
const inEye = eyeDistance <= 1.25;
|
|
302
|
+
if (inEye && transform.glance !== 0 && !transform.blink) lx = su - transform.glance * EYE_RADIUS * 0.3;
|
|
303
|
+
sampleBilinear(source, lx * side, ly * side, sample);
|
|
304
|
+
if (
|
|
305
|
+
inEye &&
|
|
306
|
+
(transform.blink ||
|
|
307
|
+
(transform.glance !== 0 && Math.hypot((lx - eye.x) / EYE_RADIUS, (ly - eye.y) / EYE_RADIUS) > 1.25))
|
|
308
|
+
) {
|
|
309
|
+
// Skin from just above the eye covers it; a blink adds a closed-lid line.
|
|
310
|
+
sampleBilinear(source, su * side, (eye.y - EYE_RADIUS * 2.1) * side, bodyAbove);
|
|
311
|
+
sample.set(bodyAbove);
|
|
312
|
+
const lid =
|
|
313
|
+
transform.blink && Math.abs((sv - eye.y - EYE_RADIUS * 0.15) / EYE_RADIUS) < 0.22 && eyeDistance <= 1.05;
|
|
314
|
+
if (lid) {
|
|
315
|
+
sample[0] = LID_DARK[0] * sample[3]!;
|
|
316
|
+
sample[1] = LID_DARK[1] * sample[3]!;
|
|
317
|
+
sample[2] = LID_DARK[2] * sample[3]!;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (transform.tear > 0) {
|
|
321
|
+
const outer = eyeIndex === 0 ? -1 : 1;
|
|
322
|
+
const tx = eye.x + outer * EYE_RADIUS * 0.9;
|
|
323
|
+
const ty = eye.y + EYE_RADIUS * (1.2 + transform.tear * 0.8);
|
|
324
|
+
const t = Math.hypot((su - tx) / (EYE_RADIUS * 0.45), (sv - ty) / (EYE_RADIUS * 0.6));
|
|
325
|
+
if (t <= 1 && sample[3]! > 0.5) {
|
|
326
|
+
sample[0] = TEAR_RGB[0];
|
|
327
|
+
sample[1] = TEAR_RGB[1];
|
|
328
|
+
sample[2] = TEAR_RGB[2];
|
|
329
|
+
sample[3] = 1;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
canvas.set(sample, (y * work + x) * 4);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const scaled = downscale({ pixels: canvas, width: work, height: work }, size);
|
|
336
|
+
const data = new Uint8Array(size * size * 4);
|
|
337
|
+
for (let i = 0; i < size * size; i++) {
|
|
338
|
+
const a = scaled.pixels[i * 4 + 3]!;
|
|
339
|
+
if (a <= 0.002) continue;
|
|
340
|
+
const [r, g, b] = tintRgb(
|
|
341
|
+
scaled.pixels[i * 4]! / a,
|
|
342
|
+
scaled.pixels[i * 4 + 1]! / a,
|
|
343
|
+
scaled.pixels[i * 4 + 2]! / a,
|
|
344
|
+
tint,
|
|
345
|
+
);
|
|
346
|
+
data[i * 4] = Math.round(Math.min(1, r) * 255);
|
|
347
|
+
data[i * 4 + 1] = Math.round(Math.min(1, g) * 255);
|
|
348
|
+
data[i * 4 + 2] = Math.round(Math.min(1, b) * 255);
|
|
349
|
+
data[i * 4 + 3] = Math.round(Math.min(1, a) * 255);
|
|
350
|
+
}
|
|
351
|
+
return { width: size, height: size, data };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Tint ─────────────────────────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
/** Hue shift in degrees from Sayo's orange; neutral pixels (eyes, highlights) are unaffected. */
|
|
357
|
+
const TINT_HUE: Record<SayoTint, number> = { orange: 0, red: -16, blue: 184 };
|
|
358
|
+
const TINT_SATURATION: Record<SayoTint, number> = { orange: 1, red: 1.05, blue: 0.95 };
|
|
359
|
+
|
|
360
|
+
function tintRgb(r: number, g: number, b: number, tint: SayoTint): [number, number, number] {
|
|
361
|
+
if (tint === "orange") return [r, g, b];
|
|
362
|
+
const max = Math.max(r, g, b);
|
|
363
|
+
const min = Math.min(r, g, b);
|
|
364
|
+
const delta = max - min;
|
|
365
|
+
if (delta < 0.05) return [r, g, b];
|
|
366
|
+
let hue = max === r ? ((g - b) / delta) % 6 : max === g ? (b - r) / delta + 2 : (r - g) / delta + 4;
|
|
367
|
+
hue = (hue * 60 + TINT_HUE[tint] + 360) % 360;
|
|
368
|
+
const saturation = Math.min(1, (delta / max) * TINT_SATURATION[tint]);
|
|
369
|
+
const value = max;
|
|
370
|
+
const c = value * saturation;
|
|
371
|
+
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
|
372
|
+
const m = value - c;
|
|
373
|
+
const [r1, g1, b1] =
|
|
374
|
+
hue < 60
|
|
375
|
+
? [c, x, 0]
|
|
376
|
+
: hue < 120
|
|
377
|
+
? [x, c, 0]
|
|
378
|
+
: hue < 180
|
|
379
|
+
? [0, c, x]
|
|
380
|
+
: hue < 240
|
|
381
|
+
? [0, x, c]
|
|
382
|
+
: hue < 300
|
|
383
|
+
? [x, 0, c]
|
|
384
|
+
: [c, 0, x];
|
|
385
|
+
return [r1 + m, g1 + m, b1 + m];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Every pose the pet state machine names. */
|
|
389
|
+
export const SAYO_POSES = Object.keys(POSES) as SayoPose[];
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ export * from "./components/input";
|
|
|
12
12
|
export * from "./components/loader";
|
|
13
13
|
export * from "./components/markdown";
|
|
14
14
|
export * from "./components/sayknow-pet";
|
|
15
|
+
export * from "./components/sayo-placeholder";
|
|
16
|
+
export * from "./components/sayo-sprite";
|
|
15
17
|
export * from "./components/secret-input";
|
|
16
18
|
export * from "./components/select-list";
|
|
17
19
|
export * from "./components/settings-list";
|