@vellumai/cli 0.11.8 → 0.11.9-dev.202609041316.d96b917
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 +6 -7
- package/knip.json +0 -1
- 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/package.json +5 -1
- 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/local-mode/node_modules/@vellumai/service-contracts/src/desktop-presence.ts +11 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token-refresh.test.ts +2 -8
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +2 -8
- package/node_modules/@vellumai/local-mode/src/__tests__/helpers/child-process-mock.ts +55 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/retire.test.ts +2 -8
- package/node_modules/@vellumai/local-mode/src/__tests__/upgrade.test.ts +2 -8
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +2 -8
- package/node_modules/@vellumai/local-mode/src/devices.test.ts +5 -8
- package/node_modules/@vellumai/local-mode/src/index.ts +1 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +6 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +165 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +63 -6
- package/node_modules/@vellumai/service-contracts/package.json +5 -1
- 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/node_modules/@vellumai/service-contracts/src/desktop-presence.ts +11 -0
- package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-api-key-resolution.test.ts +102 -0
- package/src/__tests__/constants.test.ts +6 -2
- package/src/commands/hatch.ts +1 -367
- package/src/commands/login.ts +16 -29
- package/src/commands/teleport.ts +14 -26
- package/src/lib/assistant-api-key-resolution.ts +89 -0
- package/src/lib/aws.ts +0 -593
- package/src/lib/constants.ts +0 -2
- package/src/lib/gcp.ts +3 -671
- package/src/lib/platform-client.ts +58 -5
- package/src/__tests__/ssh-user-guard.test.ts +0 -28
- package/src/adapters/openclaw-http-server.ts +0 -209
- package/src/adapters/openclaw.ts +0 -131
- package/src/lib/openclaw-runtime-server.ts +0 -28
package/README.md
CHANGED
|
@@ -54,18 +54,17 @@ vellum hatch [species] [options]
|
|
|
54
54
|
|
|
55
55
|
#### Options
|
|
56
56
|
|
|
57
|
-
| Option | Description
|
|
58
|
-
| ------------------- |
|
|
59
|
-
| `-d` | Detached mode. Start the instance in the background without watching startup progress.
|
|
60
|
-
| `--name <name>` | Use a specific instance name instead of an auto-generated one.
|
|
61
|
-
| `--remote <target>` | Where to provision the instance. One of: `local`, `docker`, `vellum`, `
|
|
57
|
+
| Option | Description |
|
|
58
|
+
| ------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
59
|
+
| `-d` | Detached mode. Start the instance in the background without watching startup progress. |
|
|
60
|
+
| `--name <name>` | Use a specific instance name instead of an auto-generated one. |
|
|
61
|
+
| `--remote <target>` | Where to provision the instance. One of: `local`, `docker`, `vellum`, `custom`. Defaults to `local`. |
|
|
62
62
|
|
|
63
63
|
#### Remote Targets
|
|
64
64
|
|
|
65
65
|
- **`local`** -- Starts the local assistant and local gateway. Gateway source resolution order is: repo source tree, then installed `@vellumai/vellum-gateway` package.
|
|
66
66
|
- **`docker`** -- Starts the assistant, gateway, and credential service in Docker containers.
|
|
67
67
|
- **`vellum`** -- Hatches an assistant on the Vellum platform.
|
|
68
|
-
- **`gcp`** and **`aws`** -- Recognized but not supported as provisioning targets yet. The CLI exits before creating cloud resources. To self-host on AWS/GCP, SSH into the VM and run `vellum hatch` or `vellum hatch --remote docker` there.
|
|
69
68
|
- **`custom`** -- Recognized but not yet implemented.
|
|
70
69
|
|
|
71
70
|
#### Environment Variables
|
|
@@ -87,7 +86,7 @@ vellum hatch --remote docker
|
|
|
87
86
|
vellum hatch --name my-assistant --remote docker
|
|
88
87
|
```
|
|
89
88
|
|
|
90
|
-
|
|
89
|
+
To self-host on a cloud VM (AWS, GCP, or any other provider), SSH into the machine and run `vellum hatch` or `vellum hatch --remote docker` from inside it. The CLI does not provision cloud instances for you.
|
|
91
90
|
|
|
92
91
|
### `terminal`
|
|
93
92
|
|
package/knip.json
CHANGED
|
@@ -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 =
|