@vellumai/cli 0.11.9-staging.2 → 0.11.9
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/node_modules/@vellumai/avatar-manifest/package.json +2 -1
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/accent.test.ts +118 -0
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/manifest.test.ts +83 -4
- package/node_modules/@vellumai/avatar-manifest/src/accent.ts +150 -0
- package/node_modules/@vellumai/avatar-manifest/src/index.ts +7 -0
- package/node_modules/@vellumai/avatar-manifest/src/manifest.ts +55 -3
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/__tests__/accent.test.ts +118 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/__tests__/manifest.test.ts +83 -4
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/accent.ts +150 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/index.ts +7 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/manifest.ts +55 -3
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
- package/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
- package/package.json +1 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
dominantAccentHex,
|
|
5
|
+
isAvatarAccentHex,
|
|
6
|
+
normalizeAvatarAccentHex,
|
|
7
|
+
} from "../accent.js";
|
|
8
|
+
|
|
9
|
+
/** An RGBA block from `[r, g, b, a, count]` runs. */
|
|
10
|
+
function pixels(...runs: [number, number, number, number, number][]) {
|
|
11
|
+
const out: number[] = [];
|
|
12
|
+
for (const [r, g, b, a, count] of runs) {
|
|
13
|
+
for (let i = 0; i < count; i += 1) {
|
|
14
|
+
out.push(r, g, b, a);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return new Uint8ClampedArray(out);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function channels(hex: string): [number, number, number] {
|
|
21
|
+
const n = parseInt(hex.slice(1), 16);
|
|
22
|
+
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function saturation(hex: string): number {
|
|
26
|
+
const ch = channels(hex).map((c) => c / 255);
|
|
27
|
+
const max = Math.max(...ch);
|
|
28
|
+
const min = Math.min(...ch);
|
|
29
|
+
const l = (max + min) / 2;
|
|
30
|
+
return max === min ? 0 : (max - min) / (1 - Math.abs(2 * l - 1));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("isAvatarAccentHex / normalizeAvatarAccentHex", () => {
|
|
34
|
+
test("accepts #rrggbb in either case and canonicalizes to lowercase", () => {
|
|
35
|
+
expect(isAvatarAccentHex("#E9642F")).toBe(true);
|
|
36
|
+
expect(normalizeAvatarAccentHex(" #E9642F ")).toBe("#e9642f");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test.each([
|
|
40
|
+
["short form", "#abc"],
|
|
41
|
+
["no hash", "e9642f"],
|
|
42
|
+
["alpha channel", "#e9642fff"],
|
|
43
|
+
["not a string", 0xe9642f],
|
|
44
|
+
["empty", ""],
|
|
45
|
+
])("rejects %s", (_label, value) => {
|
|
46
|
+
expect(isAvatarAccentHex(value)).toBe(false);
|
|
47
|
+
expect(normalizeAvatarAccentHex(value)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("dominantAccentHex", () => {
|
|
52
|
+
test("picks the coloured mark over the white ground it sits on", () => {
|
|
53
|
+
// Nine in ten pixels are white; the accent is still the red.
|
|
54
|
+
const hex = dominantAccentHex(
|
|
55
|
+
pixels([255, 255, 255, 255, 90], [200, 30, 30, 255, 10]),
|
|
56
|
+
);
|
|
57
|
+
expect(hex).toBe("#c81e1e");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("picks the coloured mark over a black ground", () => {
|
|
61
|
+
expect(
|
|
62
|
+
dominantAccentHex(pixels([0, 0, 0, 255, 90], [40, 120, 200, 255, 10])),
|
|
63
|
+
).toBe("#2878c8");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("prefers the more prevalent of two accents", () => {
|
|
67
|
+
expect(
|
|
68
|
+
dominantAccentHex(
|
|
69
|
+
pixels([200, 30, 30, 255, 30], [40, 120, 200, 255, 50]),
|
|
70
|
+
),
|
|
71
|
+
).toBe("#2878c8");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("averages a gradient of one hue rather than splitting it", () => {
|
|
75
|
+
// Three reds close together are one bin; a lone saturated blue is not
|
|
76
|
+
// more prevalent than the three of them together.
|
|
77
|
+
const hex = dominantAccentHex(
|
|
78
|
+
pixels(
|
|
79
|
+
[200, 30, 30, 255, 10],
|
|
80
|
+
[210, 40, 35, 255, 10],
|
|
81
|
+
[190, 25, 28, 255, 10],
|
|
82
|
+
[40, 120, 200, 255, 12],
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
const [r, , b] = channels(hex!);
|
|
86
|
+
expect(r).toBeGreaterThan(b);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("keeps a gray image gray instead of inventing a hue", () => {
|
|
90
|
+
const hex = dominantAccentHex(
|
|
91
|
+
pixels([120, 120, 120, 255, 80], [122, 121, 119, 255, 20]),
|
|
92
|
+
);
|
|
93
|
+
expect(saturation(hex!)).toBeLessThan(0.05);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a stray colourful pixel does not outrank a gray image", () => {
|
|
97
|
+
const hex = dominantAccentHex(
|
|
98
|
+
pixels([128, 128, 128, 255, 98], [255, 0, 0, 255, 2]),
|
|
99
|
+
);
|
|
100
|
+
expect(saturation(hex!)).toBeLessThan(0.05);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("skips transparent pixels: a cutout's ground is not its colour", () => {
|
|
104
|
+
const cutout = dominantAccentHex(
|
|
105
|
+
pixels([0, 0, 0, 0, 90], [40, 120, 200, 255, 10]),
|
|
106
|
+
);
|
|
107
|
+
expect(cutout).toBe(dominantAccentHex(pixels([40, 120, 200, 255, 10])));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("returns null when nothing is opaque enough to count", () => {
|
|
111
|
+
expect(dominantAccentHex(pixels([12, 34, 56, 0, 40]))).toBeNull();
|
|
112
|
+
expect(dominantAccentHex(new Uint8ClampedArray())).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("returns a canonical lowercase #rrggbb", () => {
|
|
116
|
+
expect(dominantAccentHex(pixels([233, 100, 47, 255, 4]))).toBe("#e9642f");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -16,7 +16,13 @@ describe("parseAvatarManifest", () => {
|
|
|
16
16
|
test("accepts a character manifest", () => {
|
|
17
17
|
expect(
|
|
18
18
|
parseAvatarManifest({ kind: "character", traits, source: "builder" }),
|
|
19
|
-
).toEqual({
|
|
19
|
+
).toEqual({
|
|
20
|
+
kind: "character",
|
|
21
|
+
traits,
|
|
22
|
+
source: "builder",
|
|
23
|
+
image: null,
|
|
24
|
+
accent: null,
|
|
25
|
+
});
|
|
20
26
|
});
|
|
21
27
|
|
|
22
28
|
test("accepts an image manifest", () => {
|
|
@@ -25,6 +31,58 @@ describe("parseAvatarManifest", () => {
|
|
|
25
31
|
traits: null,
|
|
26
32
|
source: null,
|
|
27
33
|
image,
|
|
34
|
+
accent: null,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("keeps a well-formed accent on a character or image manifest", () => {
|
|
39
|
+
const accent = { hex: "#e9642f", source: "custom" as const };
|
|
40
|
+
expect(parseAvatarManifest({ kind: "image", image, accent })).toEqual({
|
|
41
|
+
kind: "image",
|
|
42
|
+
traits: null,
|
|
43
|
+
source: null,
|
|
44
|
+
image,
|
|
45
|
+
accent,
|
|
46
|
+
});
|
|
47
|
+
expect(parseAvatarManifest({ kind: "character", traits, accent })).toEqual({
|
|
48
|
+
kind: "character",
|
|
49
|
+
traits,
|
|
50
|
+
source: null,
|
|
51
|
+
image: null,
|
|
52
|
+
accent,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test.each([
|
|
57
|
+
["a short hex", { hex: "#abc", source: "derived" }],
|
|
58
|
+
["an unknown source", { hex: "#e9642f", source: "guess" }],
|
|
59
|
+
["a missing source", { hex: "#e9642f" }],
|
|
60
|
+
["a non-object", "#e9642f"],
|
|
61
|
+
])(
|
|
62
|
+
"normalizes %s accent to null without failing the manifest",
|
|
63
|
+
(_label, accent) => {
|
|
64
|
+
expect(parseAvatarManifest({ kind: "image", image, accent })).toEqual({
|
|
65
|
+
kind: "image",
|
|
66
|
+
traits: null,
|
|
67
|
+
source: null,
|
|
68
|
+
image,
|
|
69
|
+
accent: null,
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
test("drops an accent from a none manifest", () => {
|
|
75
|
+
expect(
|
|
76
|
+
parseAvatarManifest({
|
|
77
|
+
kind: "none",
|
|
78
|
+
accent: { hex: "#e9642f", source: "custom" },
|
|
79
|
+
}),
|
|
80
|
+
).toEqual({
|
|
81
|
+
kind: "none",
|
|
82
|
+
traits: null,
|
|
83
|
+
source: null,
|
|
84
|
+
image: null,
|
|
85
|
+
accent: null,
|
|
28
86
|
});
|
|
29
87
|
});
|
|
30
88
|
|
|
@@ -34,6 +92,7 @@ describe("parseAvatarManifest", () => {
|
|
|
34
92
|
traits: null,
|
|
35
93
|
source: null,
|
|
36
94
|
image: null,
|
|
95
|
+
accent: null,
|
|
37
96
|
});
|
|
38
97
|
});
|
|
39
98
|
|
|
@@ -61,27 +120,47 @@ describe("parseAvatarManifest", () => {
|
|
|
61
120
|
test("normalizes an unknown source to null", () => {
|
|
62
121
|
expect(
|
|
63
122
|
parseAvatarManifest({ kind: "image", image, source: "unknown" }),
|
|
64
|
-
).toEqual({
|
|
123
|
+
).toEqual({
|
|
124
|
+
kind: "image",
|
|
125
|
+
traits: null,
|
|
126
|
+
source: null,
|
|
127
|
+
image,
|
|
128
|
+
accent: null,
|
|
129
|
+
});
|
|
65
130
|
expect(parseAvatarManifest({ kind: "none", source: 7 })).toEqual({
|
|
66
131
|
kind: "none",
|
|
67
132
|
traits: null,
|
|
68
133
|
source: null,
|
|
69
134
|
image: null,
|
|
135
|
+
accent: null,
|
|
70
136
|
});
|
|
71
137
|
});
|
|
72
138
|
|
|
73
139
|
test("drops payload irrelevant to the kind", () => {
|
|
74
140
|
expect(
|
|
75
141
|
parseAvatarManifest({ kind: "image", image, traits: { bodyShape: 1 } }),
|
|
76
|
-
).toEqual({
|
|
142
|
+
).toEqual({
|
|
143
|
+
kind: "image",
|
|
144
|
+
traits: null,
|
|
145
|
+
source: null,
|
|
146
|
+
image,
|
|
147
|
+
accent: null,
|
|
148
|
+
});
|
|
77
149
|
expect(
|
|
78
150
|
parseAvatarManifest({ kind: "character", traits, image: "stale" }),
|
|
79
|
-
).toEqual({
|
|
151
|
+
).toEqual({
|
|
152
|
+
kind: "character",
|
|
153
|
+
traits,
|
|
154
|
+
source: null,
|
|
155
|
+
image: null,
|
|
156
|
+
accent: null,
|
|
157
|
+
});
|
|
80
158
|
expect(parseAvatarManifest({ kind: "none", traits, image })).toEqual({
|
|
81
159
|
kind: "none",
|
|
82
160
|
traits: null,
|
|
83
161
|
source: null,
|
|
84
162
|
image: null,
|
|
163
|
+
accent: null,
|
|
85
164
|
});
|
|
86
165
|
});
|
|
87
166
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The avatar accent: the one colour every surface that tints itself to the
|
|
3
|
+
* assistant paints with.
|
|
4
|
+
*
|
|
5
|
+
* A character avatar's accent is its palette colour. An uploaded image has no
|
|
6
|
+
* colour as data, so one is read out of its pixels here. Pure pixel math with
|
|
7
|
+
* no decoder: the daemon feeds it a raster decoded by sharp and the web feeds
|
|
8
|
+
* it a canvas, and because both go through this one function they cannot
|
|
9
|
+
* disagree about what colour an image is.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** `#rrggbb` only: the one form CSS, the native hex parsers, and the colour picker all agree on. */
|
|
13
|
+
const ACCENT_HEX_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
14
|
+
|
|
15
|
+
/** Whether `value` is a `#rrggbb` string. */
|
|
16
|
+
export function isAvatarAccentHex(value: unknown): value is string {
|
|
17
|
+
return typeof value === "string" && ACCENT_HEX_PATTERN.test(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Canonical form of an accent hex (`#rrggbb`, lowercase), or null when the
|
|
22
|
+
* input is not one. Surrounding whitespace is tolerated because the value can
|
|
23
|
+
* arrive from a text field.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeAvatarAccentHex(value: unknown): string | null {
|
|
26
|
+
if (typeof value !== "string") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
return ACCENT_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Pixels below this alpha are skipped: a cutout's transparent ground is not its colour. */
|
|
34
|
+
const MIN_ALPHA = 128;
|
|
35
|
+
|
|
36
|
+
/** Hue is bucketed this finely, so a gradient of one hue lands in one bin. */
|
|
37
|
+
const HUE_BINS = 24;
|
|
38
|
+
/** Chromatic bins are split by lightness this many ways, so a colour and its shadow stay apart. */
|
|
39
|
+
const LIGHT_BANDS = 3;
|
|
40
|
+
/** Below this saturation a pixel is counted as gray, bucketed by lightness alone. */
|
|
41
|
+
const GRAY_SATURATION = 0.15;
|
|
42
|
+
/** Gray bins by lightness. */
|
|
43
|
+
const GRAY_BANDS = 5;
|
|
44
|
+
|
|
45
|
+
interface Bin {
|
|
46
|
+
count: number;
|
|
47
|
+
r: number;
|
|
48
|
+
g: number;
|
|
49
|
+
b: number;
|
|
50
|
+
s: number;
|
|
51
|
+
l: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rgbToHsl(
|
|
55
|
+
r: number,
|
|
56
|
+
g: number,
|
|
57
|
+
b: number,
|
|
58
|
+
): { h: number; s: number; l: number } {
|
|
59
|
+
const rn = r / 255;
|
|
60
|
+
const gn = g / 255;
|
|
61
|
+
const bn = b / 255;
|
|
62
|
+
const max = Math.max(rn, gn, bn);
|
|
63
|
+
const min = Math.min(rn, gn, bn);
|
|
64
|
+
const l = (max + min) / 2;
|
|
65
|
+
const d = max - min;
|
|
66
|
+
if (d === 0) {
|
|
67
|
+
return { h: 0, s: 0, l };
|
|
68
|
+
}
|
|
69
|
+
const s = d / (1 - Math.abs(2 * l - 1));
|
|
70
|
+
let h: number;
|
|
71
|
+
if (max === rn) {
|
|
72
|
+
h = ((gn - bn) / d) % 6;
|
|
73
|
+
} else if (max === gn) {
|
|
74
|
+
h = (bn - rn) / d + 2;
|
|
75
|
+
} else {
|
|
76
|
+
h = (rn - gn) / d + 4;
|
|
77
|
+
}
|
|
78
|
+
h /= 6;
|
|
79
|
+
return { h: h < 0 ? h + 1 : h, s, l };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toHex(r: number, g: number, b: number): string {
|
|
83
|
+
const ch = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
|
|
84
|
+
return `#${((1 << 24) | (ch(r) << 16) | (ch(g) << 8) | ch(b)).toString(16).slice(1)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How much a bin's colour deserves to be the accent, per pixel in it.
|
|
89
|
+
*
|
|
90
|
+
* Population alone picks the background: most uploads are a subject on white
|
|
91
|
+
* or black. Saturation and mid lightness weight the bin instead, so a red mark
|
|
92
|
+
* on a white ground reads as red while an all-gray photo still reads as gray,
|
|
93
|
+
* because with nothing colourful to beat, the gray bin's small weight wins.
|
|
94
|
+
*/
|
|
95
|
+
function salience(s: number, l: number): number {
|
|
96
|
+
return (0.1 + 0.9 * s) * Math.max(0.1, 1 - Math.abs(l - 0.5) * 1.6);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The most prevalent accent colour in a block of RGBA pixels, as `#rrggbb`, or
|
|
101
|
+
* null when no pixel is opaque enough to count.
|
|
102
|
+
*
|
|
103
|
+
* Pixels are bucketed by hue and lightness (grays by lightness alone), each
|
|
104
|
+
* bucket is scored by population times how much it looks like an accent
|
|
105
|
+
* (saturated, mid-toned), and the winner's mean colour is the answer. The
|
|
106
|
+
* mean rather than the bucket centre, so a brand's exact orange comes back
|
|
107
|
+
* as that orange and not the nearest of twenty-four hues.
|
|
108
|
+
*/
|
|
109
|
+
export function dominantAccentHex(pixels: ArrayLike<number>): string | null {
|
|
110
|
+
const bins = new Map<number, Bin>();
|
|
111
|
+
for (let i = 0; i + 3 < pixels.length; i += 4) {
|
|
112
|
+
if (pixels[i + 3]! < MIN_ALPHA) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const r = pixels[i]!;
|
|
116
|
+
const g = pixels[i + 1]!;
|
|
117
|
+
const b = pixels[i + 2]!;
|
|
118
|
+
const { h, s, l } = rgbToHsl(r, g, b);
|
|
119
|
+
const key =
|
|
120
|
+
s < GRAY_SATURATION
|
|
121
|
+
? -1 - Math.min(GRAY_BANDS - 1, Math.floor(l * GRAY_BANDS))
|
|
122
|
+
: Math.min(HUE_BINS - 1, Math.floor(h * HUE_BINS)) * LIGHT_BANDS +
|
|
123
|
+
Math.min(LIGHT_BANDS - 1, Math.floor(l * LIGHT_BANDS));
|
|
124
|
+
let bin = bins.get(key);
|
|
125
|
+
if (!bin) {
|
|
126
|
+
bin = { count: 0, r: 0, g: 0, b: 0, s: 0, l: 0 };
|
|
127
|
+
bins.set(key, bin);
|
|
128
|
+
}
|
|
129
|
+
bin.count += 1;
|
|
130
|
+
bin.r += r;
|
|
131
|
+
bin.g += g;
|
|
132
|
+
bin.b += b;
|
|
133
|
+
bin.s += s;
|
|
134
|
+
bin.l += l;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let best: Bin | null = null;
|
|
138
|
+
let bestScore = -1;
|
|
139
|
+
for (const bin of bins.values()) {
|
|
140
|
+
const score = bin.count * salience(bin.s / bin.count, bin.l / bin.count);
|
|
141
|
+
if (score > bestScore) {
|
|
142
|
+
best = bin;
|
|
143
|
+
bestScore = score;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!best) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return toHex(best.r / best.count, best.g / best.count, best.b / best.count);
|
|
150
|
+
}
|
|
@@ -14,11 +14,18 @@ export {
|
|
|
14
14
|
AVATAR_TRAITS_FILENAME,
|
|
15
15
|
resolveAvatarDir,
|
|
16
16
|
} from "./layout.js";
|
|
17
|
+
export {
|
|
18
|
+
dominantAccentHex,
|
|
19
|
+
isAvatarAccentHex,
|
|
20
|
+
normalizeAvatarAccentHex,
|
|
21
|
+
} from "./accent.js";
|
|
17
22
|
export {
|
|
18
23
|
deriveAvatarFromLegacyFiles,
|
|
19
24
|
parseAvatarManifest,
|
|
20
25
|
} from "./manifest.js";
|
|
21
26
|
export type {
|
|
27
|
+
AvatarAccent,
|
|
28
|
+
AvatarAccentSource,
|
|
22
29
|
AvatarImageMeta,
|
|
23
30
|
AvatarKind,
|
|
24
31
|
AvatarSource,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isAvatarAccentHex } from "./accent.js";
|
|
2
|
+
|
|
1
3
|
export type AvatarKind = "character" | "image" | "none";
|
|
2
4
|
export type AvatarSource = "builder" | "upload" | "ai";
|
|
3
5
|
|
|
@@ -12,12 +14,27 @@ export interface AvatarImageMeta {
|
|
|
12
14
|
etag: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Where an accent came from. `palette`: a character's chosen colour.
|
|
19
|
+
* `derived`: read out of an uploaded image's pixels. `custom`: set by the
|
|
20
|
+
* user over an image. Only the last is worth offering a reset from.
|
|
21
|
+
*/
|
|
22
|
+
export type AvatarAccentSource = "palette" | "derived" | "custom";
|
|
23
|
+
|
|
24
|
+
/** The colour every avatar-tinted surface paints with, as `#rrggbb`. */
|
|
25
|
+
export interface AvatarAccent {
|
|
26
|
+
hex: string;
|
|
27
|
+
source: AvatarAccentSource;
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
/** The persisted manifest (`avatar.json`). */
|
|
16
31
|
export interface AvatarState {
|
|
17
32
|
kind: AvatarKind;
|
|
18
33
|
traits: CharacterTraits | null;
|
|
19
34
|
source: AvatarSource | null;
|
|
20
35
|
image: AvatarImageMeta | null;
|
|
36
|
+
/** Null for `none`, and for an image whose colour could not be read. */
|
|
37
|
+
accent: AvatarAccent | null;
|
|
21
38
|
}
|
|
22
39
|
|
|
23
40
|
const AVATAR_KINDS: ReadonlySet<string> = new Set<AvatarKind>([
|
|
@@ -32,6 +49,12 @@ const AVATAR_SOURCES: ReadonlySet<string> = new Set<AvatarSource>([
|
|
|
32
49
|
"ai",
|
|
33
50
|
]);
|
|
34
51
|
|
|
52
|
+
const AVATAR_ACCENT_SOURCES: ReadonlySet<string> = new Set<AvatarAccentSource>([
|
|
53
|
+
"palette",
|
|
54
|
+
"derived",
|
|
55
|
+
"custom",
|
|
56
|
+
]);
|
|
57
|
+
|
|
35
58
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
36
59
|
return !!value && typeof value === "object";
|
|
37
60
|
}
|
|
@@ -66,6 +89,23 @@ function isValidAvatarImageMeta(value: unknown): value is AvatarImageMeta {
|
|
|
66
89
|
);
|
|
67
90
|
}
|
|
68
91
|
|
|
92
|
+
/**
|
|
93
|
+
* An accent is optional on the wire and in the file: manifests written before
|
|
94
|
+
* accents existed carry none, and an unreadable image has none. Anything not
|
|
95
|
+
* a well-formed accent normalizes to null rather than failing the manifest.
|
|
96
|
+
*/
|
|
97
|
+
function parseAvatarAccent(value: unknown): AvatarAccent | null {
|
|
98
|
+
if (
|
|
99
|
+
!isRecord(value) ||
|
|
100
|
+
!isAvatarAccentHex(value.hex) ||
|
|
101
|
+
typeof value.source !== "string" ||
|
|
102
|
+
!AVATAR_ACCENT_SOURCES.has(value.source)
|
|
103
|
+
) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return { hex: value.hex, source: value.source as AvatarAccentSource };
|
|
107
|
+
}
|
|
108
|
+
|
|
69
109
|
/**
|
|
70
110
|
* Validates a parsed `avatar.json`. Returns `null` for a non-object, an
|
|
71
111
|
* invalid or missing `kind`, or a valid `kind` whose per-kind payload is
|
|
@@ -90,15 +130,27 @@ export function parseAvatarManifest(value: unknown): AvatarState | null {
|
|
|
90
130
|
if (!isValidCharacterTraits(value.traits)) {
|
|
91
131
|
return null;
|
|
92
132
|
}
|
|
93
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
kind,
|
|
135
|
+
traits: value.traits,
|
|
136
|
+
source,
|
|
137
|
+
image: null,
|
|
138
|
+
accent: parseAvatarAccent(value.accent),
|
|
139
|
+
};
|
|
94
140
|
}
|
|
95
141
|
if (kind === "image") {
|
|
96
142
|
if (!isValidAvatarImageMeta(value.image)) {
|
|
97
143
|
return null;
|
|
98
144
|
}
|
|
99
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
kind,
|
|
147
|
+
traits: null,
|
|
148
|
+
source,
|
|
149
|
+
image: value.image,
|
|
150
|
+
accent: parseAvatarAccent(value.accent),
|
|
151
|
+
};
|
|
100
152
|
}
|
|
101
|
-
return { kind, traits: null, source, image: null };
|
|
153
|
+
return { kind, traits: null, source, image: null, accent: null };
|
|
102
154
|
}
|
|
103
155
|
|
|
104
156
|
type LegacyAvatarDerivation =
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
dominantAccentHex,
|
|
5
|
+
isAvatarAccentHex,
|
|
6
|
+
normalizeAvatarAccentHex,
|
|
7
|
+
} from "../accent.js";
|
|
8
|
+
|
|
9
|
+
/** An RGBA block from `[r, g, b, a, count]` runs. */
|
|
10
|
+
function pixels(...runs: [number, number, number, number, number][]) {
|
|
11
|
+
const out: number[] = [];
|
|
12
|
+
for (const [r, g, b, a, count] of runs) {
|
|
13
|
+
for (let i = 0; i < count; i += 1) {
|
|
14
|
+
out.push(r, g, b, a);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return new Uint8ClampedArray(out);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function channels(hex: string): [number, number, number] {
|
|
21
|
+
const n = parseInt(hex.slice(1), 16);
|
|
22
|
+
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function saturation(hex: string): number {
|
|
26
|
+
const ch = channels(hex).map((c) => c / 255);
|
|
27
|
+
const max = Math.max(...ch);
|
|
28
|
+
const min = Math.min(...ch);
|
|
29
|
+
const l = (max + min) / 2;
|
|
30
|
+
return max === min ? 0 : (max - min) / (1 - Math.abs(2 * l - 1));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("isAvatarAccentHex / normalizeAvatarAccentHex", () => {
|
|
34
|
+
test("accepts #rrggbb in either case and canonicalizes to lowercase", () => {
|
|
35
|
+
expect(isAvatarAccentHex("#E9642F")).toBe(true);
|
|
36
|
+
expect(normalizeAvatarAccentHex(" #E9642F ")).toBe("#e9642f");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test.each([
|
|
40
|
+
["short form", "#abc"],
|
|
41
|
+
["no hash", "e9642f"],
|
|
42
|
+
["alpha channel", "#e9642fff"],
|
|
43
|
+
["not a string", 0xe9642f],
|
|
44
|
+
["empty", ""],
|
|
45
|
+
])("rejects %s", (_label, value) => {
|
|
46
|
+
expect(isAvatarAccentHex(value)).toBe(false);
|
|
47
|
+
expect(normalizeAvatarAccentHex(value)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("dominantAccentHex", () => {
|
|
52
|
+
test("picks the coloured mark over the white ground it sits on", () => {
|
|
53
|
+
// Nine in ten pixels are white; the accent is still the red.
|
|
54
|
+
const hex = dominantAccentHex(
|
|
55
|
+
pixels([255, 255, 255, 255, 90], [200, 30, 30, 255, 10]),
|
|
56
|
+
);
|
|
57
|
+
expect(hex).toBe("#c81e1e");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("picks the coloured mark over a black ground", () => {
|
|
61
|
+
expect(
|
|
62
|
+
dominantAccentHex(pixels([0, 0, 0, 255, 90], [40, 120, 200, 255, 10])),
|
|
63
|
+
).toBe("#2878c8");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("prefers the more prevalent of two accents", () => {
|
|
67
|
+
expect(
|
|
68
|
+
dominantAccentHex(
|
|
69
|
+
pixels([200, 30, 30, 255, 30], [40, 120, 200, 255, 50]),
|
|
70
|
+
),
|
|
71
|
+
).toBe("#2878c8");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("averages a gradient of one hue rather than splitting it", () => {
|
|
75
|
+
// Three reds close together are one bin; a lone saturated blue is not
|
|
76
|
+
// more prevalent than the three of them together.
|
|
77
|
+
const hex = dominantAccentHex(
|
|
78
|
+
pixels(
|
|
79
|
+
[200, 30, 30, 255, 10],
|
|
80
|
+
[210, 40, 35, 255, 10],
|
|
81
|
+
[190, 25, 28, 255, 10],
|
|
82
|
+
[40, 120, 200, 255, 12],
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
const [r, , b] = channels(hex!);
|
|
86
|
+
expect(r).toBeGreaterThan(b);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("keeps a gray image gray instead of inventing a hue", () => {
|
|
90
|
+
const hex = dominantAccentHex(
|
|
91
|
+
pixels([120, 120, 120, 255, 80], [122, 121, 119, 255, 20]),
|
|
92
|
+
);
|
|
93
|
+
expect(saturation(hex!)).toBeLessThan(0.05);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a stray colourful pixel does not outrank a gray image", () => {
|
|
97
|
+
const hex = dominantAccentHex(
|
|
98
|
+
pixels([128, 128, 128, 255, 98], [255, 0, 0, 255, 2]),
|
|
99
|
+
);
|
|
100
|
+
expect(saturation(hex!)).toBeLessThan(0.05);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("skips transparent pixels: a cutout's ground is not its colour", () => {
|
|
104
|
+
const cutout = dominantAccentHex(
|
|
105
|
+
pixels([0, 0, 0, 0, 90], [40, 120, 200, 255, 10]),
|
|
106
|
+
);
|
|
107
|
+
expect(cutout).toBe(dominantAccentHex(pixels([40, 120, 200, 255, 10])));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("returns null when nothing is opaque enough to count", () => {
|
|
111
|
+
expect(dominantAccentHex(pixels([12, 34, 56, 0, 40]))).toBeNull();
|
|
112
|
+
expect(dominantAccentHex(new Uint8ClampedArray())).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("returns a canonical lowercase #rrggbb", () => {
|
|
116
|
+
expect(dominantAccentHex(pixels([233, 100, 47, 255, 4]))).toBe("#e9642f");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -16,7 +16,13 @@ describe("parseAvatarManifest", () => {
|
|
|
16
16
|
test("accepts a character manifest", () => {
|
|
17
17
|
expect(
|
|
18
18
|
parseAvatarManifest({ kind: "character", traits, source: "builder" }),
|
|
19
|
-
).toEqual({
|
|
19
|
+
).toEqual({
|
|
20
|
+
kind: "character",
|
|
21
|
+
traits,
|
|
22
|
+
source: "builder",
|
|
23
|
+
image: null,
|
|
24
|
+
accent: null,
|
|
25
|
+
});
|
|
20
26
|
});
|
|
21
27
|
|
|
22
28
|
test("accepts an image manifest", () => {
|
|
@@ -25,6 +31,58 @@ describe("parseAvatarManifest", () => {
|
|
|
25
31
|
traits: null,
|
|
26
32
|
source: null,
|
|
27
33
|
image,
|
|
34
|
+
accent: null,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("keeps a well-formed accent on a character or image manifest", () => {
|
|
39
|
+
const accent = { hex: "#e9642f", source: "custom" as const };
|
|
40
|
+
expect(parseAvatarManifest({ kind: "image", image, accent })).toEqual({
|
|
41
|
+
kind: "image",
|
|
42
|
+
traits: null,
|
|
43
|
+
source: null,
|
|
44
|
+
image,
|
|
45
|
+
accent,
|
|
46
|
+
});
|
|
47
|
+
expect(parseAvatarManifest({ kind: "character", traits, accent })).toEqual({
|
|
48
|
+
kind: "character",
|
|
49
|
+
traits,
|
|
50
|
+
source: null,
|
|
51
|
+
image: null,
|
|
52
|
+
accent,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test.each([
|
|
57
|
+
["a short hex", { hex: "#abc", source: "derived" }],
|
|
58
|
+
["an unknown source", { hex: "#e9642f", source: "guess" }],
|
|
59
|
+
["a missing source", { hex: "#e9642f" }],
|
|
60
|
+
["a non-object", "#e9642f"],
|
|
61
|
+
])(
|
|
62
|
+
"normalizes %s accent to null without failing the manifest",
|
|
63
|
+
(_label, accent) => {
|
|
64
|
+
expect(parseAvatarManifest({ kind: "image", image, accent })).toEqual({
|
|
65
|
+
kind: "image",
|
|
66
|
+
traits: null,
|
|
67
|
+
source: null,
|
|
68
|
+
image,
|
|
69
|
+
accent: null,
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
test("drops an accent from a none manifest", () => {
|
|
75
|
+
expect(
|
|
76
|
+
parseAvatarManifest({
|
|
77
|
+
kind: "none",
|
|
78
|
+
accent: { hex: "#e9642f", source: "custom" },
|
|
79
|
+
}),
|
|
80
|
+
).toEqual({
|
|
81
|
+
kind: "none",
|
|
82
|
+
traits: null,
|
|
83
|
+
source: null,
|
|
84
|
+
image: null,
|
|
85
|
+
accent: null,
|
|
28
86
|
});
|
|
29
87
|
});
|
|
30
88
|
|
|
@@ -34,6 +92,7 @@ describe("parseAvatarManifest", () => {
|
|
|
34
92
|
traits: null,
|
|
35
93
|
source: null,
|
|
36
94
|
image: null,
|
|
95
|
+
accent: null,
|
|
37
96
|
});
|
|
38
97
|
});
|
|
39
98
|
|
|
@@ -61,27 +120,47 @@ describe("parseAvatarManifest", () => {
|
|
|
61
120
|
test("normalizes an unknown source to null", () => {
|
|
62
121
|
expect(
|
|
63
122
|
parseAvatarManifest({ kind: "image", image, source: "unknown" }),
|
|
64
|
-
).toEqual({
|
|
123
|
+
).toEqual({
|
|
124
|
+
kind: "image",
|
|
125
|
+
traits: null,
|
|
126
|
+
source: null,
|
|
127
|
+
image,
|
|
128
|
+
accent: null,
|
|
129
|
+
});
|
|
65
130
|
expect(parseAvatarManifest({ kind: "none", source: 7 })).toEqual({
|
|
66
131
|
kind: "none",
|
|
67
132
|
traits: null,
|
|
68
133
|
source: null,
|
|
69
134
|
image: null,
|
|
135
|
+
accent: null,
|
|
70
136
|
});
|
|
71
137
|
});
|
|
72
138
|
|
|
73
139
|
test("drops payload irrelevant to the kind", () => {
|
|
74
140
|
expect(
|
|
75
141
|
parseAvatarManifest({ kind: "image", image, traits: { bodyShape: 1 } }),
|
|
76
|
-
).toEqual({
|
|
142
|
+
).toEqual({
|
|
143
|
+
kind: "image",
|
|
144
|
+
traits: null,
|
|
145
|
+
source: null,
|
|
146
|
+
image,
|
|
147
|
+
accent: null,
|
|
148
|
+
});
|
|
77
149
|
expect(
|
|
78
150
|
parseAvatarManifest({ kind: "character", traits, image: "stale" }),
|
|
79
|
-
).toEqual({
|
|
151
|
+
).toEqual({
|
|
152
|
+
kind: "character",
|
|
153
|
+
traits,
|
|
154
|
+
source: null,
|
|
155
|
+
image: null,
|
|
156
|
+
accent: null,
|
|
157
|
+
});
|
|
80
158
|
expect(parseAvatarManifest({ kind: "none", traits, image })).toEqual({
|
|
81
159
|
kind: "none",
|
|
82
160
|
traits: null,
|
|
83
161
|
source: null,
|
|
84
162
|
image: null,
|
|
163
|
+
accent: null,
|
|
85
164
|
});
|
|
86
165
|
});
|
|
87
166
|
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/accent.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The avatar accent: the one colour every surface that tints itself to the
|
|
3
|
+
* assistant paints with.
|
|
4
|
+
*
|
|
5
|
+
* A character avatar's accent is its palette colour. An uploaded image has no
|
|
6
|
+
* colour as data, so one is read out of its pixels here. Pure pixel math with
|
|
7
|
+
* no decoder: the daemon feeds it a raster decoded by sharp and the web feeds
|
|
8
|
+
* it a canvas, and because both go through this one function they cannot
|
|
9
|
+
* disagree about what colour an image is.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** `#rrggbb` only: the one form CSS, the native hex parsers, and the colour picker all agree on. */
|
|
13
|
+
const ACCENT_HEX_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
14
|
+
|
|
15
|
+
/** Whether `value` is a `#rrggbb` string. */
|
|
16
|
+
export function isAvatarAccentHex(value: unknown): value is string {
|
|
17
|
+
return typeof value === "string" && ACCENT_HEX_PATTERN.test(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Canonical form of an accent hex (`#rrggbb`, lowercase), or null when the
|
|
22
|
+
* input is not one. Surrounding whitespace is tolerated because the value can
|
|
23
|
+
* arrive from a text field.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeAvatarAccentHex(value: unknown): string | null {
|
|
26
|
+
if (typeof value !== "string") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
return ACCENT_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Pixels below this alpha are skipped: a cutout's transparent ground is not its colour. */
|
|
34
|
+
const MIN_ALPHA = 128;
|
|
35
|
+
|
|
36
|
+
/** Hue is bucketed this finely, so a gradient of one hue lands in one bin. */
|
|
37
|
+
const HUE_BINS = 24;
|
|
38
|
+
/** Chromatic bins are split by lightness this many ways, so a colour and its shadow stay apart. */
|
|
39
|
+
const LIGHT_BANDS = 3;
|
|
40
|
+
/** Below this saturation a pixel is counted as gray, bucketed by lightness alone. */
|
|
41
|
+
const GRAY_SATURATION = 0.15;
|
|
42
|
+
/** Gray bins by lightness. */
|
|
43
|
+
const GRAY_BANDS = 5;
|
|
44
|
+
|
|
45
|
+
interface Bin {
|
|
46
|
+
count: number;
|
|
47
|
+
r: number;
|
|
48
|
+
g: number;
|
|
49
|
+
b: number;
|
|
50
|
+
s: number;
|
|
51
|
+
l: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rgbToHsl(
|
|
55
|
+
r: number,
|
|
56
|
+
g: number,
|
|
57
|
+
b: number,
|
|
58
|
+
): { h: number; s: number; l: number } {
|
|
59
|
+
const rn = r / 255;
|
|
60
|
+
const gn = g / 255;
|
|
61
|
+
const bn = b / 255;
|
|
62
|
+
const max = Math.max(rn, gn, bn);
|
|
63
|
+
const min = Math.min(rn, gn, bn);
|
|
64
|
+
const l = (max + min) / 2;
|
|
65
|
+
const d = max - min;
|
|
66
|
+
if (d === 0) {
|
|
67
|
+
return { h: 0, s: 0, l };
|
|
68
|
+
}
|
|
69
|
+
const s = d / (1 - Math.abs(2 * l - 1));
|
|
70
|
+
let h: number;
|
|
71
|
+
if (max === rn) {
|
|
72
|
+
h = ((gn - bn) / d) % 6;
|
|
73
|
+
} else if (max === gn) {
|
|
74
|
+
h = (bn - rn) / d + 2;
|
|
75
|
+
} else {
|
|
76
|
+
h = (rn - gn) / d + 4;
|
|
77
|
+
}
|
|
78
|
+
h /= 6;
|
|
79
|
+
return { h: h < 0 ? h + 1 : h, s, l };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toHex(r: number, g: number, b: number): string {
|
|
83
|
+
const ch = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
|
|
84
|
+
return `#${((1 << 24) | (ch(r) << 16) | (ch(g) << 8) | ch(b)).toString(16).slice(1)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How much a bin's colour deserves to be the accent, per pixel in it.
|
|
89
|
+
*
|
|
90
|
+
* Population alone picks the background: most uploads are a subject on white
|
|
91
|
+
* or black. Saturation and mid lightness weight the bin instead, so a red mark
|
|
92
|
+
* on a white ground reads as red while an all-gray photo still reads as gray,
|
|
93
|
+
* because with nothing colourful to beat, the gray bin's small weight wins.
|
|
94
|
+
*/
|
|
95
|
+
function salience(s: number, l: number): number {
|
|
96
|
+
return (0.1 + 0.9 * s) * Math.max(0.1, 1 - Math.abs(l - 0.5) * 1.6);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The most prevalent accent colour in a block of RGBA pixels, as `#rrggbb`, or
|
|
101
|
+
* null when no pixel is opaque enough to count.
|
|
102
|
+
*
|
|
103
|
+
* Pixels are bucketed by hue and lightness (grays by lightness alone), each
|
|
104
|
+
* bucket is scored by population times how much it looks like an accent
|
|
105
|
+
* (saturated, mid-toned), and the winner's mean colour is the answer. The
|
|
106
|
+
* mean rather than the bucket centre, so a brand's exact orange comes back
|
|
107
|
+
* as that orange and not the nearest of twenty-four hues.
|
|
108
|
+
*/
|
|
109
|
+
export function dominantAccentHex(pixels: ArrayLike<number>): string | null {
|
|
110
|
+
const bins = new Map<number, Bin>();
|
|
111
|
+
for (let i = 0; i + 3 < pixels.length; i += 4) {
|
|
112
|
+
if (pixels[i + 3]! < MIN_ALPHA) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const r = pixels[i]!;
|
|
116
|
+
const g = pixels[i + 1]!;
|
|
117
|
+
const b = pixels[i + 2]!;
|
|
118
|
+
const { h, s, l } = rgbToHsl(r, g, b);
|
|
119
|
+
const key =
|
|
120
|
+
s < GRAY_SATURATION
|
|
121
|
+
? -1 - Math.min(GRAY_BANDS - 1, Math.floor(l * GRAY_BANDS))
|
|
122
|
+
: Math.min(HUE_BINS - 1, Math.floor(h * HUE_BINS)) * LIGHT_BANDS +
|
|
123
|
+
Math.min(LIGHT_BANDS - 1, Math.floor(l * LIGHT_BANDS));
|
|
124
|
+
let bin = bins.get(key);
|
|
125
|
+
if (!bin) {
|
|
126
|
+
bin = { count: 0, r: 0, g: 0, b: 0, s: 0, l: 0 };
|
|
127
|
+
bins.set(key, bin);
|
|
128
|
+
}
|
|
129
|
+
bin.count += 1;
|
|
130
|
+
bin.r += r;
|
|
131
|
+
bin.g += g;
|
|
132
|
+
bin.b += b;
|
|
133
|
+
bin.s += s;
|
|
134
|
+
bin.l += l;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let best: Bin | null = null;
|
|
138
|
+
let bestScore = -1;
|
|
139
|
+
for (const bin of bins.values()) {
|
|
140
|
+
const score = bin.count * salience(bin.s / bin.count, bin.l / bin.count);
|
|
141
|
+
if (score > bestScore) {
|
|
142
|
+
best = bin;
|
|
143
|
+
bestScore = score;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!best) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return toHex(best.r / best.count, best.g / best.count, best.b / best.count);
|
|
150
|
+
}
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/index.ts
CHANGED
|
@@ -14,11 +14,18 @@ export {
|
|
|
14
14
|
AVATAR_TRAITS_FILENAME,
|
|
15
15
|
resolveAvatarDir,
|
|
16
16
|
} from "./layout.js";
|
|
17
|
+
export {
|
|
18
|
+
dominantAccentHex,
|
|
19
|
+
isAvatarAccentHex,
|
|
20
|
+
normalizeAvatarAccentHex,
|
|
21
|
+
} from "./accent.js";
|
|
17
22
|
export {
|
|
18
23
|
deriveAvatarFromLegacyFiles,
|
|
19
24
|
parseAvatarManifest,
|
|
20
25
|
} from "./manifest.js";
|
|
21
26
|
export type {
|
|
27
|
+
AvatarAccent,
|
|
28
|
+
AvatarAccentSource,
|
|
22
29
|
AvatarImageMeta,
|
|
23
30
|
AvatarKind,
|
|
24
31
|
AvatarSource,
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/manifest.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isAvatarAccentHex } from "./accent.js";
|
|
2
|
+
|
|
1
3
|
export type AvatarKind = "character" | "image" | "none";
|
|
2
4
|
export type AvatarSource = "builder" | "upload" | "ai";
|
|
3
5
|
|
|
@@ -12,12 +14,27 @@ export interface AvatarImageMeta {
|
|
|
12
14
|
etag: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Where an accent came from. `palette`: a character's chosen colour.
|
|
19
|
+
* `derived`: read out of an uploaded image's pixels. `custom`: set by the
|
|
20
|
+
* user over an image. Only the last is worth offering a reset from.
|
|
21
|
+
*/
|
|
22
|
+
export type AvatarAccentSource = "palette" | "derived" | "custom";
|
|
23
|
+
|
|
24
|
+
/** The colour every avatar-tinted surface paints with, as `#rrggbb`. */
|
|
25
|
+
export interface AvatarAccent {
|
|
26
|
+
hex: string;
|
|
27
|
+
source: AvatarAccentSource;
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
/** The persisted manifest (`avatar.json`). */
|
|
16
31
|
export interface AvatarState {
|
|
17
32
|
kind: AvatarKind;
|
|
18
33
|
traits: CharacterTraits | null;
|
|
19
34
|
source: AvatarSource | null;
|
|
20
35
|
image: AvatarImageMeta | null;
|
|
36
|
+
/** Null for `none`, and for an image whose colour could not be read. */
|
|
37
|
+
accent: AvatarAccent | null;
|
|
21
38
|
}
|
|
22
39
|
|
|
23
40
|
const AVATAR_KINDS: ReadonlySet<string> = new Set<AvatarKind>([
|
|
@@ -32,6 +49,12 @@ const AVATAR_SOURCES: ReadonlySet<string> = new Set<AvatarSource>([
|
|
|
32
49
|
"ai",
|
|
33
50
|
]);
|
|
34
51
|
|
|
52
|
+
const AVATAR_ACCENT_SOURCES: ReadonlySet<string> = new Set<AvatarAccentSource>([
|
|
53
|
+
"palette",
|
|
54
|
+
"derived",
|
|
55
|
+
"custom",
|
|
56
|
+
]);
|
|
57
|
+
|
|
35
58
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
36
59
|
return !!value && typeof value === "object";
|
|
37
60
|
}
|
|
@@ -66,6 +89,23 @@ function isValidAvatarImageMeta(value: unknown): value is AvatarImageMeta {
|
|
|
66
89
|
);
|
|
67
90
|
}
|
|
68
91
|
|
|
92
|
+
/**
|
|
93
|
+
* An accent is optional on the wire and in the file: manifests written before
|
|
94
|
+
* accents existed carry none, and an unreadable image has none. Anything not
|
|
95
|
+
* a well-formed accent normalizes to null rather than failing the manifest.
|
|
96
|
+
*/
|
|
97
|
+
function parseAvatarAccent(value: unknown): AvatarAccent | null {
|
|
98
|
+
if (
|
|
99
|
+
!isRecord(value) ||
|
|
100
|
+
!isAvatarAccentHex(value.hex) ||
|
|
101
|
+
typeof value.source !== "string" ||
|
|
102
|
+
!AVATAR_ACCENT_SOURCES.has(value.source)
|
|
103
|
+
) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return { hex: value.hex, source: value.source as AvatarAccentSource };
|
|
107
|
+
}
|
|
108
|
+
|
|
69
109
|
/**
|
|
70
110
|
* Validates a parsed `avatar.json`. Returns `null` for a non-object, an
|
|
71
111
|
* invalid or missing `kind`, or a valid `kind` whose per-kind payload is
|
|
@@ -90,15 +130,27 @@ export function parseAvatarManifest(value: unknown): AvatarState | null {
|
|
|
90
130
|
if (!isValidCharacterTraits(value.traits)) {
|
|
91
131
|
return null;
|
|
92
132
|
}
|
|
93
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
kind,
|
|
135
|
+
traits: value.traits,
|
|
136
|
+
source,
|
|
137
|
+
image: null,
|
|
138
|
+
accent: parseAvatarAccent(value.accent),
|
|
139
|
+
};
|
|
94
140
|
}
|
|
95
141
|
if (kind === "image") {
|
|
96
142
|
if (!isValidAvatarImageMeta(value.image)) {
|
|
97
143
|
return null;
|
|
98
144
|
}
|
|
99
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
kind,
|
|
147
|
+
traits: null,
|
|
148
|
+
source,
|
|
149
|
+
image: value.image,
|
|
150
|
+
accent: parseAvatarAccent(value.accent),
|
|
151
|
+
};
|
|
100
152
|
}
|
|
101
|
-
return { kind, traits: null, source, image: null };
|
|
153
|
+
return { kind, traits: null, source, image: null, accent: null };
|
|
102
154
|
}
|
|
103
155
|
|
|
104
156
|
type LegacyAvatarDerivation =
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
CHANNEL_BOT_PROVIDER,
|
|
5
|
+
CHANNEL_IDS,
|
|
6
|
+
isChannelBotProvider,
|
|
7
|
+
isChannelUserIntegration,
|
|
8
|
+
isChannelId,
|
|
9
|
+
} from "../channels.js";
|
|
4
10
|
|
|
5
11
|
describe("isChannelId", () => {
|
|
6
12
|
test("accepts every canonical channel id", () => {
|
|
@@ -34,3 +40,36 @@ describe("isChannelId", () => {
|
|
|
34
40
|
expect(isChannelId(42)).toBe(false);
|
|
35
41
|
});
|
|
36
42
|
});
|
|
43
|
+
|
|
44
|
+
describe("isChannelUserIntegration", () => {
|
|
45
|
+
test("names the grant standing beside a bot of the same brand", () => {
|
|
46
|
+
expect(isChannelUserIntegration("slack")).toBe(true);
|
|
47
|
+
expect(isChannelUserIntegration("discord")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("excludes a channel whose bot is its own key", () => {
|
|
51
|
+
// `telegram` names the bot, so no second provider carries the brand and
|
|
52
|
+
// there is nothing to mistake for it.
|
|
53
|
+
expect(isChannelUserIntegration("telegram")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("excludes the bots themselves and unrelated providers", () => {
|
|
57
|
+
expect(isChannelUserIntegration("slack_channel")).toBe(false);
|
|
58
|
+
expect(isChannelUserIntegration("discord_channel")).toBe(false);
|
|
59
|
+
expect(isChannelUserIntegration("google")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("is the complement of isChannelBotProvider over the map", () => {
|
|
63
|
+
// The two questions partition the brands that have both halves: a key is
|
|
64
|
+
// one or the other, never both, so a caller picking the wrong one gets an
|
|
65
|
+
// empty answer rather than a plausible wrong one.
|
|
66
|
+
for (const [channelId, botProviderKey] of Object.entries(
|
|
67
|
+
CHANNEL_BOT_PROVIDER,
|
|
68
|
+
)) {
|
|
69
|
+
expect(isChannelUserIntegration(channelId)).toBe(
|
|
70
|
+
channelId !== botProviderKey,
|
|
71
|
+
);
|
|
72
|
+
expect(isChannelBotProvider(botProviderKey)).toBe(true);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/channels.ts
CHANGED
|
@@ -97,3 +97,24 @@ export function isChannelBotProvider(providerKey: string): boolean {
|
|
|
97
97
|
providerKey,
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether a provider key names the user grant of a brand the assistant is
|
|
103
|
+
* reached through some other way: `slack` beside `slack_channel`, `discord`
|
|
104
|
+
* beside `discord_channel`. The other half of the question
|
|
105
|
+
* `isChannelBotProvider` asks.
|
|
106
|
+
*
|
|
107
|
+
* The two halves carry the same brand name, so the grant reads as the way to
|
|
108
|
+
* connect that brand when the bot is. A surface offering people a way to
|
|
109
|
+
* connect an assistant wants the bot, and wants this to say which keys it is
|
|
110
|
+
* answering for rather than naming them itself.
|
|
111
|
+
*
|
|
112
|
+
* A channel whose bot is its own key has no such pair: `telegram` names the
|
|
113
|
+
* bot, so nothing is standing beside it to be mistaken for.
|
|
114
|
+
*/
|
|
115
|
+
export function isChannelUserIntegration(providerKey: string): boolean {
|
|
116
|
+
return Object.entries(CHANNEL_BOT_PROVIDER).some(
|
|
117
|
+
([channelId, botProviderKey]) =>
|
|
118
|
+
channelId === providerKey && botProviderKey !== providerKey,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
CHANNEL_BOT_PROVIDER,
|
|
5
|
+
CHANNEL_IDS,
|
|
6
|
+
isChannelBotProvider,
|
|
7
|
+
isChannelUserIntegration,
|
|
8
|
+
isChannelId,
|
|
9
|
+
} from "../channels.js";
|
|
4
10
|
|
|
5
11
|
describe("isChannelId", () => {
|
|
6
12
|
test("accepts every canonical channel id", () => {
|
|
@@ -34,3 +40,36 @@ describe("isChannelId", () => {
|
|
|
34
40
|
expect(isChannelId(42)).toBe(false);
|
|
35
41
|
});
|
|
36
42
|
});
|
|
43
|
+
|
|
44
|
+
describe("isChannelUserIntegration", () => {
|
|
45
|
+
test("names the grant standing beside a bot of the same brand", () => {
|
|
46
|
+
expect(isChannelUserIntegration("slack")).toBe(true);
|
|
47
|
+
expect(isChannelUserIntegration("discord")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("excludes a channel whose bot is its own key", () => {
|
|
51
|
+
// `telegram` names the bot, so no second provider carries the brand and
|
|
52
|
+
// there is nothing to mistake for it.
|
|
53
|
+
expect(isChannelUserIntegration("telegram")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("excludes the bots themselves and unrelated providers", () => {
|
|
57
|
+
expect(isChannelUserIntegration("slack_channel")).toBe(false);
|
|
58
|
+
expect(isChannelUserIntegration("discord_channel")).toBe(false);
|
|
59
|
+
expect(isChannelUserIntegration("google")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("is the complement of isChannelBotProvider over the map", () => {
|
|
63
|
+
// The two questions partition the brands that have both halves: a key is
|
|
64
|
+
// one or the other, never both, so a caller picking the wrong one gets an
|
|
65
|
+
// empty answer rather than a plausible wrong one.
|
|
66
|
+
for (const [channelId, botProviderKey] of Object.entries(
|
|
67
|
+
CHANNEL_BOT_PROVIDER,
|
|
68
|
+
)) {
|
|
69
|
+
expect(isChannelUserIntegration(channelId)).toBe(
|
|
70
|
+
channelId !== botProviderKey,
|
|
71
|
+
);
|
|
72
|
+
expect(isChannelBotProvider(botProviderKey)).toBe(true);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -97,3 +97,24 @@ export function isChannelBotProvider(providerKey: string): boolean {
|
|
|
97
97
|
providerKey,
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether a provider key names the user grant of a brand the assistant is
|
|
103
|
+
* reached through some other way: `slack` beside `slack_channel`, `discord`
|
|
104
|
+
* beside `discord_channel`. The other half of the question
|
|
105
|
+
* `isChannelBotProvider` asks.
|
|
106
|
+
*
|
|
107
|
+
* The two halves carry the same brand name, so the grant reads as the way to
|
|
108
|
+
* connect that brand when the bot is. A surface offering people a way to
|
|
109
|
+
* connect an assistant wants the bot, and wants this to say which keys it is
|
|
110
|
+
* answering for rather than naming them itself.
|
|
111
|
+
*
|
|
112
|
+
* A channel whose bot is its own key has no such pair: `telegram` names the
|
|
113
|
+
* bot, so nothing is standing beside it to be mistaken for.
|
|
114
|
+
*/
|
|
115
|
+
export function isChannelUserIntegration(providerKey: string): boolean {
|
|
116
|
+
return Object.entries(CHANNEL_BOT_PROVIDER).some(
|
|
117
|
+
([channelId, botProviderKey]) =>
|
|
118
|
+
channelId === providerKey && botProviderKey !== providerKey,
|
|
119
|
+
);
|
|
120
|
+
}
|