shadepix 0.3.0__tar.gz

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.
shadepix-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bastien Cochet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: shadepix
3
+ Version: 0.3.0
4
+ Summary: Faceless pixel avatars from your terminal. Zero dependencies.
5
+ Author: Bastien Cochet
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bstcoc/shade
8
+ Keywords: pixel-art,avatar,cli,terminal,generator
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ <p align="center">
15
+ <img src="assets/wordmark.png" width="420" alt="shade">
16
+ </p>
17
+
18
+ <p align="center">
19
+ <b>Faceless pixel avatars from your terminal.</b><br>
20
+ One Python file. Zero dependencies. Every face stays blank.<br><br>
21
+ <a href="https://bstcoc.github.io/shade/"><b>Try it in your browser →</b></a>
22
+ </p>
23
+
24
+ <p align="center">
25
+ <img src="assets/examples.png" alt="Avatars made with shade">
26
+ </p>
27
+
28
+ ## Why
29
+
30
+ Most avatar makers draw a face. shade draws everything else: hair, beard, hat, clothes, background.
31
+ Eyes are always covered (shades, visor, goggles, a band), and there is no nose and no mouth.
32
+ You get an avatar that feels like you without showing a face.
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ uv tool install git+https://github.com/bstcoc/shade
38
+ # or: pipx install git+https://github.com/bstcoc/shade
39
+ ```
40
+
41
+ Needs Python 3.10+ and nothing else. Works on macOS, Linux and Windows. The command is `shade`.
42
+
43
+ ## Use it
44
+
45
+ Run `shade` to open the studio:
46
+
47
+ <p align="center"><img src="assets/demo.gif" width="760" alt="The shade studio"></p>
48
+
49
+ - `↑` `↓` pick a setting, `←` `→` change it, and the preview updates live
50
+ - `space` shuffles the look and keeps your size, shape and detail level
51
+ - `⏎` saves a PNG and `g` saves an animated GIF, then you can keep going
52
+
53
+ You can also make an avatar without the studio:
54
+
55
+ ```sh
56
+ shade curly visor hoodie # words in any order
57
+ shade topcolor=navy bg=night stars # colors take group=value
58
+ shade random beanie # random look, with a beanie
59
+ shade curly visor gif # animated GIF: a light streak sweeps the lenses
60
+ shade @egtuw4yi # rebuild an avatar from its code
61
+ shade help # every group and value
62
+ ```
63
+
64
+ Every avatar has a short code like `@egtuw4yi`. Share it and anyone can rebuild the same avatar.
65
+
66
+ ### What you can change
67
+
68
+ 17 settings: head shape, skin, hair, hair color, beard, hat, hat color, eyewear, lens color, top, top color, extra, background, pattern, detail level (`rich`, `shaded`, `flat`), shape (`rounded`, `circle`, `square`) and size (200, 400 or 1200 px).
69
+
70
+ ## Use it from an agent
71
+
72
+ shade ships a skill for Claude Code. Install it once and ask for a pixel avatar in plain words:
73
+
74
+ ```sh
75
+ mkdir -p ~/.claude/skills/shade
76
+ curl -fsSL -o ~/.claude/skills/shade/SKILL.md https://raw.githubusercontent.com/bstcoc/shade/main/skills/shade/SKILL.md
77
+ ```
78
+
79
+ ## Let any AI design one
80
+
81
+ ```sh
82
+ shade agent
83
+ ```
84
+
85
+ This copies a ready-made prompt to your clipboard. Paste it to any AI and it replies with a `shade` command, or with a pixel grid you render using `shade draw`.
86
+
87
+ ## Draw your own
88
+
89
+ A grid is a text file. A line like `K #0d0d14` sets a color, every other line is a row of pixels, and `.` is transparent.
90
+
91
+ ```
92
+ K #0d0d14
93
+ r #ff0000
94
+ .K.
95
+ KrK
96
+ .K.
97
+ ```
98
+
99
+ ```sh
100
+ shade draw art.txt
101
+ ```
102
+
103
+ ## Releases
104
+
105
+ Every merged release PR publishes a new version to PyPI and GitHub, and the [web demo](https://bstcoc.github.io/shade/) picks it up with its notes. See [CHANGELOG.md](CHANGELOG.md).
106
+
107
+ ## License
108
+
109
+ [MIT](LICENSE)
@@ -0,0 +1,96 @@
1
+ <p align="center">
2
+ <img src="assets/wordmark.png" width="420" alt="shade">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <b>Faceless pixel avatars from your terminal.</b><br>
7
+ One Python file. Zero dependencies. Every face stays blank.<br><br>
8
+ <a href="https://bstcoc.github.io/shade/"><b>Try it in your browser →</b></a>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <img src="assets/examples.png" alt="Avatars made with shade">
13
+ </p>
14
+
15
+ ## Why
16
+
17
+ Most avatar makers draw a face. shade draws everything else: hair, beard, hat, clothes, background.
18
+ Eyes are always covered (shades, visor, goggles, a band), and there is no nose and no mouth.
19
+ You get an avatar that feels like you without showing a face.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ uv tool install git+https://github.com/bstcoc/shade
25
+ # or: pipx install git+https://github.com/bstcoc/shade
26
+ ```
27
+
28
+ Needs Python 3.10+ and nothing else. Works on macOS, Linux and Windows. The command is `shade`.
29
+
30
+ ## Use it
31
+
32
+ Run `shade` to open the studio:
33
+
34
+ <p align="center"><img src="assets/demo.gif" width="760" alt="The shade studio"></p>
35
+
36
+ - `↑` `↓` pick a setting, `←` `→` change it, and the preview updates live
37
+ - `space` shuffles the look and keeps your size, shape and detail level
38
+ - `⏎` saves a PNG and `g` saves an animated GIF, then you can keep going
39
+
40
+ You can also make an avatar without the studio:
41
+
42
+ ```sh
43
+ shade curly visor hoodie # words in any order
44
+ shade topcolor=navy bg=night stars # colors take group=value
45
+ shade random beanie # random look, with a beanie
46
+ shade curly visor gif # animated GIF: a light streak sweeps the lenses
47
+ shade @egtuw4yi # rebuild an avatar from its code
48
+ shade help # every group and value
49
+ ```
50
+
51
+ Every avatar has a short code like `@egtuw4yi`. Share it and anyone can rebuild the same avatar.
52
+
53
+ ### What you can change
54
+
55
+ 17 settings: head shape, skin, hair, hair color, beard, hat, hat color, eyewear, lens color, top, top color, extra, background, pattern, detail level (`rich`, `shaded`, `flat`), shape (`rounded`, `circle`, `square`) and size (200, 400 or 1200 px).
56
+
57
+ ## Use it from an agent
58
+
59
+ shade ships a skill for Claude Code. Install it once and ask for a pixel avatar in plain words:
60
+
61
+ ```sh
62
+ mkdir -p ~/.claude/skills/shade
63
+ curl -fsSL -o ~/.claude/skills/shade/SKILL.md https://raw.githubusercontent.com/bstcoc/shade/main/skills/shade/SKILL.md
64
+ ```
65
+
66
+ ## Let any AI design one
67
+
68
+ ```sh
69
+ shade agent
70
+ ```
71
+
72
+ This copies a ready-made prompt to your clipboard. Paste it to any AI and it replies with a `shade` command, or with a pixel grid you render using `shade draw`.
73
+
74
+ ## Draw your own
75
+
76
+ A grid is a text file. A line like `K #0d0d14` sets a color, every other line is a row of pixels, and `.` is transparent.
77
+
78
+ ```
79
+ K #0d0d14
80
+ r #ff0000
81
+ .K.
82
+ KrK
83
+ .K.
84
+ ```
85
+
86
+ ```sh
87
+ shade draw art.txt
88
+ ```
89
+
90
+ ## Releases
91
+
92
+ Every merged release PR publishes a new version to PyPI and GitHub, and the [web demo](https://bstcoc.github.io/shade/) picks it up with its notes. See [CHANGELOG.md](CHANGELOG.md).
93
+
94
+ ## License
95
+
96
+ [MIT](LICENSE)
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "shadepix"
3
+ version = "0.3.0"
4
+ description = "Faceless pixel avatars from your terminal. Zero dependencies."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "Bastien Cochet" }]
9
+ keywords = ["pixel-art", "avatar", "cli", "terminal", "generator"]
10
+
11
+ [project.urls]
12
+ Homepage = "https://github.com/bstcoc/shade"
13
+
14
+ [project.scripts]
15
+ shade = "shade:main"
16
+
17
+ [build-system]
18
+ requires = ["setuptools>=61"]
19
+ build-backend = "setuptools.build_meta"
20
+
21
+ [tool.setuptools]
22
+ py-modules = ["shade"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,875 @@
1
+ #!/usr/bin/env python3
2
+ """shade: faceless pixel avatars from your terminal. Zero dependencies.
3
+
4
+ House rule: no eyes, no nose, no mouth. Eyes are always covered and faces stay blank.
5
+ """
6
+ import os, random, re, shutil, struct, sys, time, zlib
7
+
8
+ __version__ = "0.3.0" # x-release-please-version
9
+
10
+ N, C = 40, 20 # canvas is N x N; a half-width hw covers columns C-hw .. C+hw-1
11
+ FRAMES = 24 # one animation loop; every moving pattern repeats within it
12
+
13
+ # ── options ──────────────────────────────────────────────────────────────────
14
+
15
+ SKIN = {"porcelain": "f7dcc9", "light": "edc4a3", "sand": "dcaa80", "tan": "c48c5f",
16
+ "brown": "93603e", "deep": "62402a", "cocoa": "432c1e"}
17
+ HAIR = {"black": "26232f", "brown": "5a3722", "auburn": "8a422b", "blonde": "e3bd62",
18
+ "ginger": "d26b33", "gray": "a0a0aa", "white": "ececf2", "blue": "4874e2",
19
+ "pink": "ec74b2", "green": "45b06f", "purple": "8658da"}
20
+ FABRIC = {"black": "2a2933", "white": "ecebf0", "red": "dc4b4b", "orange": "f28e3e",
21
+ "yellow": "f3cc4f", "green": "43a96e", "teal": "2fb6ab", "blue": "4074e3",
22
+ "navy": "2c3a66", "purple": "8254da", "pink": "ef74b2", "olive": "6e7c3e"}
23
+ LENS = {"black": "18171f", "smoke": "40434f", "blue": "2f7bff", "red": "ff3d5e",
24
+ "gold": "ffb627", "green": "22d18c", "purple": "9b5cff"}
25
+ BG = {"lilac": "b9a3ff", "sky": "6aa8e8", "mint": "8fe3c0", "peach": "ffb38a",
26
+ "lemon": "ffe27a", "coral": "ff7a7a", "cream": "f3ead8", "night": "22254a",
27
+ "forest": "2f5d48", "ink": "15141c"}
28
+
29
+ OPTIONS = {
30
+ "head": ["round", "oval", "square", "long", "wide"],
31
+ "skin": SKIN,
32
+ "hair": ["short", "side", "buzz", "curly", "long", "bun", "mohawk", "spiky", "bald"],
33
+ "haircolor": HAIR,
34
+ "beard": ["none", "stubble", "short", "full", "long"],
35
+ "hat": ["none", "cap", "beanie", "bandana", "headphones"],
36
+ "hatcolor": FABRIC,
37
+ "eyes": ["shades", "round", "visor", "band", "goggles", "vr"],
38
+ "lens": LENS,
39
+ "top": ["hoodie", "tee", "jacket", "turtleneck"],
40
+ "topcolor": FABRIC,
41
+ "extra": ["none", "earring", "chain"],
42
+ "bg": BG,
43
+ "pattern": ["plain", "gradient", "dots", "stripes", "stars", "grid"],
44
+ "detail": ["rich", "shaded", "flat"],
45
+ "shape": ["rounded", "circle", "square"],
46
+ "size": ["400px", "1200px", "200px"],
47
+ }
48
+ LABELS = {"haircolor": "hair color", "hatcolor": "hat color", "topcolor": "top color", "bg": "background"}
49
+ DEFAULT = {"head": "round", "skin": "sand", "hair": "side", "haircolor": "brown", "beard": "short",
50
+ "hat": "none", "hatcolor": "red", "eyes": "shades", "lens": "blue", "top": "hoodie",
51
+ "topcolor": "orange", "extra": "none", "bg": "lilac", "pattern": "plain",
52
+ "detail": "rich", "shape": "rounded", "size": "400px"}
53
+ KEEP_ON_SHUFFLE = ("detail", "shape", "size")
54
+
55
+ # (top row, half-width of each row)
56
+ HEADS = {"round": (9, [4, 6, 7, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 7, 7, 6, 5, 4]),
57
+ "oval": (9, [3, 5, 6, 7, 7, 8, 8, 8, 8, 8, 8, 8, 7, 7, 7, 6, 6, 5, 4, 3]),
58
+ "square": (9, [6, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 7, 7, 6]),
59
+ "long": (8, [4, 5, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 6, 5, 4, 3]),
60
+ "wide": (10, [5, 7, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 8, 8, 7, 6, 5])}
61
+ BODY = [9, 12, 13, 14, 14, 15, 15, 15, 15, 15] # rows 30..39
62
+
63
+ # Parts whose edges draw an inner outline on what lies beneath them.
64
+ CASTERS = {"head", "hair", "hat", "top", "eyes"}
65
+ STARS = [(4, 5), (33, 4), (36, 15), (3, 22), (35, 27), (8, 13), (29, 9), (5, 34), (13, 3)]
66
+ BAYER = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]
67
+
68
+
69
+ # ── colors ───────────────────────────────────────────────────────────────────
70
+
71
+ def rgb(h):
72
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
73
+
74
+
75
+ def mix(a, b, t):
76
+ return "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(rgb(a), rgb(b)))
77
+
78
+
79
+ def luma(h):
80
+ r, g, b = rgb(h)
81
+ return 0.299 * r + 0.587 * g + 0.114 * b
82
+
83
+
84
+ def tone(base, suffix):
85
+ """'-' shadow (hue-shifted to violet), '--' deep shadow, '+' warm highlight."""
86
+ return {"": base, "-": mix(base, "2b1f4f", .32), "--": mix(base, "2b1f4f", .5),
87
+ "+": mix(base, "fff6dc", .35)}[suffix]
88
+
89
+
90
+ def palette(o):
91
+ p = {"skin": SKIN[o["skin"]], "hair": HAIR[o["haircolor"]], "top": FABRIC[o["topcolor"]],
92
+ "hat": FABRIC[o["hatcolor"]], "lens": LENS[o["lens"]], "frame": "1d1b26",
93
+ "white": "f1f0f5", "gold": "f6c445", "vr": "d8dae3", "line": "1b1724", "bg": BG[o["bg"]]}
94
+ p["glint"] = mix(p["lens"], "ffffff", .75)
95
+ p["stubble"] = mix(p["skin"], p["hair"], .45)
96
+ p["bg2"] = mix(p["bg"], "ffffff", .18) if luma(p["bg"]) < 110 else mix(p["bg"], "2b1f4f", .12)
97
+ return p
98
+
99
+
100
+ # ── drawing ──────────────────────────────────────────────────────────────────
101
+
102
+ def compose(o):
103
+ """Paint every part into {(x, y): [key, z, part]}; higher z wins."""
104
+ px = {}
105
+ y0, hws = HEADS[o["head"]]
106
+ chin, W0, W = y0 + len(hws) - 1, hws[0], max(hws)
107
+ ey = y0 + 8 # eye line, always covered
108
+ hw = lambda y: hws[y - y0] if y0 <= y <= chin else 0
109
+
110
+ def put(x, y, key, z, part):
111
+ if 0 <= x < N and 0 <= y < N and z >= px.get((x, y), ("", -1, ""))[1]:
112
+ px[x, y] = [key, z, part]
113
+
114
+ def band(y, h, key, z, part):
115
+ for x in range(C - h, C + h):
116
+ put(x, y, key, z, part)
117
+
118
+ def disc(cx, cy, r, key, z, part, ymax=N):
119
+ for y in range(max(0, int(cy - r) - 1), min(ymax, int(cy + r) + 2)):
120
+ for x in range(int(cx - r) - 1, int(cx + r) + 2):
121
+ if (x + .5 - cx) ** 2 + (y + .5 - cy) ** 2 <= r * r:
122
+ put(x, y, key, z, part)
123
+
124
+ def mirror(x, y, key, z, part): # x measured from the left edge of the canvas
125
+ put(x, y, key, z, part)
126
+ put(2 * C - 1 - x, y, key, z, part)
127
+
128
+ # body, neck, head
129
+ for i, h in enumerate(BODY):
130
+ band(30 + i, h, "top", 3, "top")
131
+ for y in range(chin - 2, 32):
132
+ band(y, 3, "skin", 2, "neck")
133
+ for i, h in enumerate(hws):
134
+ band(y0 + i, h, "skin", 4, "head")
135
+ for y in range(ey + 1, ey + 4):
136
+ mirror(C - hw(y) - 1, y, "skin", 4, "head")
137
+
138
+ # top
139
+ top = o["top"]
140
+ if top in ("tee", "hoodie"):
141
+ band(30, 3, "skin", 3.5, "neck")
142
+ band(31, 2, "skin", 3.5, "neck")
143
+ if top == "hoodie":
144
+ for y, h in ((chin - 1, 6), (chin, 7), (chin + 1, 7), (29, 7)):
145
+ band(y, h, "top", 1.5, "top")
146
+ for y in range(31, 35):
147
+ mirror(C - 2, y, "white", 3.2, "top")
148
+ if top == "jacket":
149
+ for i, h in enumerate((4, 3, 3, 2, 2, 1)):
150
+ band(30 + i, h, "white", 3.2, "shirt")
151
+ for y in range(36, N):
152
+ put(C - 1, y, "top--", 3.2, "top")
153
+ if top == "turtleneck":
154
+ for y in range(chin - 1, 32):
155
+ band(y, 4, "top", 3.6, "top")
156
+
157
+ # beard
158
+ beard, B = o["beard"], ("hair", 5, "beard")
159
+ if beard in ("short", "full", "long"):
160
+ for y in range(ey + 3, ey + 6):
161
+ h = hw(y)
162
+ for x in (C - h, C - h + 1, C + h - 2, C + h - 1):
163
+ put(x, y, *B)
164
+ for y in range(ey + 6, chin + 1):
165
+ band(y, hw(y), *B)
166
+ tail = {"short": [hw(chin) - 2], "full": [hw(chin) - 1, hw(chin) - 3],
167
+ "long": [hw(chin) - 1, hw(chin) - 2, 3, 3, 2, 2, 1]}[beard]
168
+ for i, h in enumerate(tail):
169
+ band(chin + 1 + i, max(h, 1), *B)
170
+ if beard == "stubble":
171
+ for y in range(ey + 5, chin + 1):
172
+ for x in range(C - hw(y), C + hw(y)):
173
+ if (x + y) % 2 == 0:
174
+ put(x, y, "stubble", 5, "beard")
175
+
176
+ # hair
177
+ hair, H, Hb = o["hair"], ("hair", 6, "hair"), ("hair", 1, "back")
178
+
179
+ def scalp(extra=1):
180
+ band(y0 - 2, W0 - 1, *H)
181
+ band(y0 - 1, W0 + 1, *H)
182
+ for y in range(y0, y0 + 4):
183
+ band(y, hw(y) + extra, *H)
184
+
185
+ def sideburns():
186
+ for y in range(y0 + 4, ey + 2):
187
+ h = hw(y)
188
+ for x in (C - h - 1, C - h, C + h - 1, C + h):
189
+ put(x, y, *H)
190
+
191
+ if hair in ("short", "side", "spiky", "bun", "long"):
192
+ scalp()
193
+ sideburns()
194
+ if hair in ("short", "spiky", "bun"):
195
+ for x in range(C - hw(y0 + 4), C + hw(y0 + 4)):
196
+ if x % 3 != 1:
197
+ put(x, y0 + 4, *H)
198
+ if hair == "side":
199
+ for y, (a, b) in ((y0 + 4, (-9, 5)), (y0 + 5, (-9, 1)), (y0 + 6, (-9, -4))):
200
+ for x in range(C + a, C + b):
201
+ put(x, y, *H)
202
+ if hair == "buzz":
203
+ band(y0 - 1, W0 - 1, *H)
204
+ for y in range(y0, y0 + 3):
205
+ band(y, hw(y), *H)
206
+ if hair == "curly":
207
+ disc(C, y0 + 6, W + 4, *Hb, ymax=chin - 3)
208
+ band(y0 - 3, W0, *H)
209
+ scalp(extra=2)
210
+ for x in range(C - hw(y0 + 4), C + hw(y0 + 4)):
211
+ if x % 2 == 0:
212
+ put(x, y0 + 4, *H)
213
+ if hair == "long":
214
+ band(y0 - 1, W0 + 2, *Hb)
215
+ for y in range(y0, y0 + 22):
216
+ band(y, max(hw(y), W - 1) + 2, *Hb)
217
+ for y in range(y0 + 4, y0 + 20):
218
+ h = hw(y) or W - 1
219
+ for x in (C - h - 2, C - h - 1, C - h, C + h - 1, C + h, C + h + 1):
220
+ put(x, y, *H)
221
+ if hair == "bun":
222
+ disc(C, y0 - 3, 3.3, *H)
223
+ if hair == "mohawk":
224
+ for y in range(y0 - 5, y0 + 5):
225
+ band(y, 1 if y == y0 - 5 else 2, *H)
226
+ if hair == "spiky":
227
+ for sx, h in ((C - 8, 3), (C - 5, 4), (C - 1, 5), (C + 3, 4), (C + 6, 3)):
228
+ for j in range(h):
229
+ r = (h - 1 - j) // 2
230
+ for x in range(sx - r, sx + r + 2):
231
+ put(x, y0 - 2 - j, *H)
232
+
233
+ # hat
234
+ hat, T = o["hat"], ("hat", 7, "hat")
235
+ if hat in ("cap", "beanie"):
236
+ for k in [k for k, v in px.items() if v[2] == "hair" and k[1] < y0 + 2]:
237
+ del px[k]
238
+ if hat == "cap":
239
+ band(y0 - 3, W0 - 1, *T)
240
+ band(y0 - 2, W0 + 1, *T)
241
+ band(y0 - 1, W0 + 2, *T)
242
+ for y in range(y0, y0 + 4):
243
+ band(y, hw(y) + 1, *T)
244
+ band(y0 + 4, W + 2, *T)
245
+ band(y0 + 5, W, "hat-", 7, "hat")
246
+ if hat == "beanie":
247
+ disc(C, y0 - 5, 2.3, "hat+", 7, "hat")
248
+ band(y0 - 4, W - 3, *T)
249
+ band(y0 - 3, W - 1, *T)
250
+ band(y0 - 2, W, *T)
251
+ for y in range(y0 - 1, y0 + 6):
252
+ h = W + 1
253
+ for x in range(C - h, C + h):
254
+ put(x, y, "hat-" if y >= y0 + 3 and x % 2 == 0 else "hat", 7, "hat")
255
+ if hat == "bandana":
256
+ for y in range(y0 + 1, y0 + 4):
257
+ for x in range(C - hw(y) - 1, C + hw(y) + 1):
258
+ put(x, y, "hat+" if y == y0 + 2 and x % 3 == 0 else "hat", 7, "hat")
259
+ h = hw(y0 + 3)
260
+ for dx, dy in ((1, 3), (2, 3), (2, 4), (3, 5), (1, 4), (1, 5), (2, 6)):
261
+ put(C + h + dx, y0 + dy, *T)
262
+ if hat == "headphones":
263
+ r = W + 2.5
264
+ for y in range(y0 - 4, ey + 1):
265
+ for x in range(N):
266
+ d = ((x + .5 - C) ** 2 + (y + .5 - ey - 1) ** 2) ** .5
267
+ if r - 1.4 < d <= r:
268
+ put(x, y, *T)
269
+ h = hw(ey)
270
+ for y in range(ey - 1, ey + 5):
271
+ for i in range(3):
272
+ mirror(C - h - 3 + i, y, "hat-" if i == 2 else "hat", 9.5, "hat")
273
+
274
+ # eyes: the slot is always covered
275
+ eyes, h = o["eyes"], hw(ey)
276
+ E = ("frame", 8, "eyes")
277
+ if eyes == "shades":
278
+ band(ey, h + 1, *E)
279
+ for x in range(C - h + 1, C - 1):
280
+ mirror(x, ey + 1, "lens", 8, "eyes")
281
+ for x in range(C - h + 2, C - 2):
282
+ mirror(x, ey + 2, "lens", 8, "eyes")
283
+ put(C - h + 1, ey + 1, "glint", 8, "eyes")
284
+ put(C - h + 2, ey + 1, "glint", 8, "eyes")
285
+ if eyes == "round":
286
+ for cx in (C - 4, C + 4):
287
+ disc(cx, ey + 1.5, 2.7, "lens", 8, "eyes")
288
+ put(C - 6, ey + 1, "glint", 8, "eyes")
289
+ put(C - 5, ey, "glint", 8, "eyes")
290
+ for x in range(C - h - 1, C - 6):
291
+ mirror(x, ey + 1, *E)
292
+ mirror(C - 1, ey + 1, *E)
293
+ if eyes == "visor":
294
+ for y in range(ey, ey + 3):
295
+ band(y, h + 1, "lens", 8, "eyes")
296
+ for x in range(C - h + 1, C - h + 5):
297
+ put(x, ey + 1, "glint", 8, "eyes")
298
+ if eyes == "band":
299
+ for y in range(ey, ey + 3):
300
+ band(y, h + 1, "lens", 8, "eyes")
301
+ for dx, dy in ((1, 2), (2, 3), (1, 4), (3, 4), (2, 5)):
302
+ put(C + h + dx, ey + dy, "lens", 8, "eyes")
303
+ if eyes == "goggles":
304
+ band(ey + 1, h + 1, *E)
305
+ for cx in (C - 4, C + 4):
306
+ disc(cx, ey + 1.5, 3.2, "lens", 8, "eyes")
307
+ put(C - 6, ey + 1, "glint", 8, "eyes")
308
+ put(C - 5, ey, "glint", 8, "eyes")
309
+ if eyes == "vr":
310
+ for y in range(ey - 1, ey + 4):
311
+ band(y, h + 1, "vr", 8, "eyes")
312
+ for y in range(ey, ey + 3):
313
+ band(y, h - 1, "lens", 8, "eyes")
314
+ put(C + h - 3, ey + 1, "glint", 8, "eyes")
315
+
316
+ # extras
317
+ if o["extra"] == "earring":
318
+ put(C - hw(ey + 3) - 1, ey + 4, "gold", 9, "extra")
319
+ if o["extra"] == "chain":
320
+ for x, y in ((C - 4, 30), (C - 3, 31), (C - 2, 32), (C - 1, 32)):
321
+ mirror(x, y, "gold", 9, "extra")
322
+ band(33, 1, "gold", 9, "extra")
323
+ return px
324
+
325
+
326
+ def light(px, o):
327
+ """Cel shading: right side in shadow, cast shadow under hair, highlights at 'rich'."""
328
+ if o["detail"] == "flat":
329
+ return
330
+ rich = o["detail"] == "rich"
331
+ boxes = {}
332
+ for (x, y), (_, _, part) in px.items():
333
+ b = boxes.setdefault(part, [x, y, x, y])
334
+ b[:] = min(b[0], x), min(b[1], y), max(b[2], x), max(b[3], y)
335
+ for (x, y), v in px.items():
336
+ key, _, part = v
337
+ if key not in ("skin", "hair", "top", "hat"):
338
+ continue
339
+ x0, y0, x1, y1 = boxes[part]
340
+ cut = x0 + .62 * (x1 - x0 + 1)
341
+ above = px.get((x, y - 1), ("", 0, ""))[2]
342
+ if part == "neck" or (part == "head" and above in ("hair", "hat")):
343
+ v[0] = key + "-"
344
+ elif x >= cut or (rich and part in ("hair", "back", "hat") and x == int(cut) - 1 and (x + y) % 2 == 0):
345
+ v[0] = key + "-"
346
+ elif rich and part in ("hair", "hat", "back") and x < x0 + .3 * (x1 - x0) and y < y0 + .35 * (y1 - y0):
347
+ v[0] = key + "+"
348
+ elif rich and o["hair"] == "curly" and key == "hair" and (3 * x + 5 * y) % 7 == 0:
349
+ v[0] = key + "-"
350
+
351
+
352
+ def background(x, y, o, t=0):
353
+ pat = o["pattern"]
354
+ if pat == "stripes":
355
+ x -= t // 3
356
+ if pat == "dots":
357
+ x, y = x + t // 6, y + t // 6
358
+ if pat == "grid":
359
+ x, y = x - t // 4, y - t // 4
360
+ alt = {"plain": False, "gradient": BAYER[y % 4][x % 4] < y / N * 18 - 1,
361
+ "dots": x % 4 == 1 and y % 4 == 1, "stripes": (x + y) % 8 < 2,
362
+ "grid": x % 6 == 0 or y % 6 == 0,
363
+ "stars": any((x, y) in ((sx, sy), (sx + 1, sy), (sx - 1, sy), (sx, sy + 1), (sx, sy - 1))
364
+ and (sx + sy + t // 6) % 2 == 0 or (x, y) == (sx, sy) for sx, sy in STARS)}[pat]
365
+ return "bg2" if alt else "bg"
366
+
367
+
368
+ def inside(x, y, shape, n=N):
369
+ if shape == "circle":
370
+ return (x + .5 - n / 2) ** 2 + (y + .5 - n / 2) ** 2 <= (n / 2) ** 2
371
+ if shape == "rounded":
372
+ r = n / 8
373
+ cx, cy = min(max(x + .5, r), n - r), min(max(y + .5, r), n - r)
374
+ return (x + .5 - cx) ** 2 + (y + .5 - cy) ** 2 <= r * r
375
+ return True
376
+
377
+
378
+ def render(o, t=0):
379
+ """Options -> N x N grid of hex colors (None = transparent). t is the animation frame."""
380
+ px, pal = compose(o), palette(o)
381
+ light(px, o)
382
+ lens = [k for k, v in px.items() if v[0] == "lens"]
383
+ if t and lens and o["eyes"] != "band": # a light streak sweeps across the lenses
384
+ lo, hi = min(x + y for x, y in lens), max(x + y for x, y in lens)
385
+ p = lo - 3 + t * (hi - lo + 6) / 10
386
+ for x, y in lens:
387
+ if p <= x + y < p + 2:
388
+ px[x, y][0] = "glint"
389
+ color = lambda key: tone(pal[key.rstrip("+-")], key[len(key.rstrip("+-")):])
390
+ grid = [[None] * N for _ in range(N)]
391
+ for y in range(N):
392
+ for x in range(N):
393
+ if not inside(x, y, o["shape"]):
394
+ continue
395
+ key, z, part = px.get((x, y), (background(x, y, o, t), 0, "bg"))
396
+ caster = None
397
+ for dx, dy in ((0, -1), (-1, 0), (1, 0), (0, 1)):
398
+ q = px.get((x + dx, y + dy))
399
+ if q and q[1] > z and (part == "bg" or q[2] in CASTERS and (q[2] != "eyes" or dy <= 0)):
400
+ caster = q
401
+ break
402
+ if caster is None:
403
+ grid[y][x] = color(key)
404
+ elif o["detail"] == "rich":
405
+ grid[y][x] = mix(color(caster[0]), "120e1c", .7) # colored outline
406
+ else:
407
+ grid[y][x] = pal["line"]
408
+ return grid
409
+
410
+
411
+ def png(grid, scale):
412
+ """Grid of hex/None -> RGBA PNG bytes."""
413
+ lines = []
414
+ for row in grid:
415
+ line = b"".join((bytes.fromhex(c) + b"\xff" if c else b"\0\0\0\0") * scale for c in row)
416
+ lines += [line] * scale
417
+ w, h = len(grid[0]) * scale, len(lines)
418
+ chunk = lambda t, d: struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d))
419
+ return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0))
420
+ + chunk(b"IDAT", zlib.compress(b"".join(b"\0" + l for l in lines), 9)) + chunk(b"IEND", b""))
421
+
422
+
423
+ def lzw(data, min_size):
424
+ """GIF flavored LZW: variable code size, table reset at 4096 codes."""
425
+ clear = 1 << min_size
426
+ out, buf, nbits = bytearray(), 0, 0
427
+
428
+ def emit(code):
429
+ nonlocal buf, nbits
430
+ buf |= code << nbits
431
+ nbits += size
432
+ while nbits >= 8:
433
+ out.append(buf & 255)
434
+ buf >>= 8
435
+ nbits -= 8
436
+
437
+ size, nxt, table = min_size + 1, clear + 2, {}
438
+ emit(clear)
439
+ w = data[0]
440
+ for b in data[1:]:
441
+ if (w, b) in table:
442
+ w = table[w, b]
443
+ continue
444
+ emit(w)
445
+ if nxt < 4096:
446
+ table[w, b] = nxt
447
+ nxt += 1
448
+ if nxt > 1 << size:
449
+ size += 1
450
+ else:
451
+ emit(clear)
452
+ size, nxt, table = min_size + 1, clear + 2, {}
453
+ w = b
454
+ emit(w)
455
+ emit(clear + 1)
456
+ if nbits:
457
+ out.append(buf & 255)
458
+ return bytes([min_size]) + b"".join(bytes([len(out[i:i + 255])]) + out[i:i + 255]
459
+ for i in range(0, len(out), 255)) + b"\0"
460
+
461
+
462
+ def gif(grids, scale, delay=8):
463
+ """Grids of hex/None -> looping animated GIF bytes. delay is in 1/100 s per grid."""
464
+ runs = [] # merge identical consecutive frames into one longer frame
465
+ for g in grids:
466
+ if runs and runs[-1][0] == g:
467
+ runs[-1][1] += delay
468
+ else:
469
+ runs.append([g, delay])
470
+ colors = sorted({c for g, _ in runs for row in g for c in row if c})
471
+ index = {c: i + 1 for i, c in enumerate(colors)} # 0 is transparent
472
+ bits = max(2, len(colors).bit_length())
473
+ table = b"\0\0\0" + b"".join(bytes.fromhex(c) for c in colors) + b"\0\0\0" * ((1 << bits) - len(colors) - 1)
474
+ w, h = len(grids[0][0]) * scale, len(grids[0]) * scale
475
+ out = b"GIF89a" + struct.pack("<HHBBB", w, h, 0xF0 | (bits - 1), 0, 0) + table
476
+ out += b"\x21\xff\x0bNETSCAPE2.0\x03\x01\0\0\0"
477
+ for g, d in runs:
478
+ pixels = b"".join(bytes(index.get(c, 0) for c in row for _ in range(scale)) * scale for row in g)
479
+ out += b"\x21\xf9\x04" + struct.pack("<BHBB", 0x09, d, 0, 0)
480
+ out += b"\x2c" + struct.pack("<HHHHB", 0, 0, w, h, 0) + lzw(pixels, bits)
481
+ return out + b"\x3b"
482
+
483
+
484
+ def animate(o):
485
+ return gif([render(o, t) for t in range(FRAMES)], min(int(o["size"][:-2]), 400) // N) # ponytail: pure-Python LZW, GIF capped at 400px
486
+
487
+
488
+ # ── avatar codes: every look fits in a short shareable code ──────────────────
489
+
490
+ DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"
491
+
492
+
493
+ def encode(o):
494
+ n = 0
495
+ for k, v in OPTIONS.items():
496
+ n = n * len(v) + list(v).index(o[k])
497
+ s = ""
498
+ while n:
499
+ n, r = divmod(n, 36)
500
+ s = DIGITS[r] + s
501
+ return s or "0"
502
+
503
+
504
+ def decode(code):
505
+ n, o = int(code, 36), {}
506
+ for k, v in reversed(OPTIONS.items()):
507
+ n, r = divmod(n, len(v))
508
+ o[k] = list(v)[r]
509
+ if n:
510
+ raise ValueError(code)
511
+ return o
512
+
513
+
514
+ def save(o, animated=False):
515
+ name = f"shade-{encode(o)}.{'gif' if animated else 'png'}"
516
+ with open(name, "wb") as f:
517
+ f.write(animate(o) if animated else png(render(o), int(o["size"][:-2]) // N))
518
+ return name
519
+
520
+
521
+ def shuffle(o):
522
+ return {k: o[k] if k in KEEP_ON_SHUFFLE else random.choice(list(v)) for k, v in OPTIONS.items()}
523
+
524
+
525
+ # ── terminal ─────────────────────────────────────────────────────────────────
526
+
527
+ R = "\x1b[0m"
528
+ fg = lambda h: "\x1b[38;2;%d;%d;%dm" % rgb(h)
529
+ bgc = lambda h: "\x1b[48;2;%d;%d;%dm" % rgb(h)
530
+ DIM, ACCENT, OK = fg("6f6a85"), fg("c4a8ff"), fg("5ee6a0")
531
+ GRADIENT = ["ff6ec7", "b388ff", "6ec8ff"]
532
+
533
+ FONT = {"S": [".####", "#....", ".###.", "....#", "####."],
534
+ "H": ["#...#", "#...#", "#####", "#...#", "#...#"],
535
+ "A": [".###.", "#...#", "#####", "#...#", "#...#"],
536
+ "D": ["####.", "#...#", "#...#", "#...#", "####."],
537
+ "E": ["#####", "#....", "####.", "#....", "#####"]}
538
+
539
+
540
+ def ramp(t):
541
+ t = min(max(t, 0), 1) * (len(GRADIENT) - 1)
542
+ i = min(int(t), len(GRADIENT) - 2)
543
+ return mix(GRADIENT[i], GRADIENT[i + 1], t - i)
544
+
545
+
546
+ def wordmark_grid(word="SHADE", scale=1, shadow="2a2140"):
547
+ w = len(word) * 6 * scale
548
+ grid = [[None] * (w + 1) for _ in range(5 * scale + 1)]
549
+ for i, ch in enumerate(word):
550
+ for y, row in enumerate(FONT[ch]):
551
+ for x, c in enumerate(row):
552
+ if c != "#":
553
+ continue
554
+ for sy in range(scale):
555
+ for sx in range(scale):
556
+ X, Y = (i * 6 + x) * scale + sx, y * scale + sy
557
+ grid[Y + 1][X + 1] = grid[Y + 1][X + 1] or shadow
558
+ grid[Y][X] = ramp(X / w)
559
+ return grid
560
+
561
+
562
+ def halfblocks(grid, xs=1):
563
+ out = []
564
+ for y in range(0, len(grid), 2):
565
+ top, bot = grid[y], grid[y + 1] if y + 1 < len(grid) else [None] * len(grid[y])
566
+ s = ""
567
+ for t, b in zip(top, bot):
568
+ if t and b:
569
+ s += fg(t) + bgc(b) + "▀" * xs + R
570
+ elif t or b:
571
+ s += fg(t or b) + ("▀" if t else "▄") * xs + R
572
+ else:
573
+ s += " " * xs
574
+ out.append(s)
575
+ return out
576
+
577
+
578
+ def card(o):
579
+ edge = fg("3d3852")
580
+ code = f" @{encode(o)} "
581
+ rows = [edge + "╭" + "─" * N + "╮" + R]
582
+ rows += [edge + "│" + R + line + edge + "│" + R for line in halfblocks(render(o))]
583
+ rows.append(edge + "╰" + "─" * (N - len(code) - 2) + R + DIM + code + edge + "──╯" + R)
584
+ return rows
585
+
586
+
587
+ def visible(s):
588
+ return len(re.sub(r"\x1b\[[0-9;?]*[a-zA-Z]", "", s))
589
+
590
+
591
+ def frame(lines):
592
+ sys.stdout.write("\x1b[H" + "\x1b[K\n".join(lines) + "\x1b[K\x1b[J")
593
+ sys.stdout.flush()
594
+
595
+
596
+ KEYS = {"\x1b[A": "up", "\xe0H": "up", "k": "up", "\x1b[B": "down", "\xe0P": "down", "j": "down",
597
+ "\x1b[C": "right", "\xe0M": "right", "l": "right", "\x1b[D": "left", "\xe0K": "left", "h": "left",
598
+ "\r": "enter", "\n": "enter", " ": "space", "\x1b": "back", "q": "quit", "\x03": "quit"}
599
+
600
+
601
+ def key(timeout=None):
602
+ """Wait for one keypress and name it. Returns None when timeout runs out."""
603
+ if os.name == "nt":
604
+ import msvcrt
605
+ end = None if timeout is None else time.monotonic() + timeout
606
+ while not msvcrt.kbhit():
607
+ if end is not None and time.monotonic() >= end:
608
+ return None
609
+ time.sleep(0.01)
610
+ k = msvcrt.getwch()
611
+ if k in "\x00\xe0": # arrows arrive as a prefix plus a letter
612
+ k = "\xe0" + msvcrt.getwch()
613
+ return KEYS.get(k, k)
614
+ import select, termios, tty
615
+ fd = sys.stdin.fileno()
616
+ old = termios.tcgetattr(fd)
617
+ try:
618
+ tty.setraw(fd)
619
+ if not select.select([fd], [], [], timeout)[0]:
620
+ return None
621
+ k = os.read(fd, 8).decode(errors="ignore")
622
+ finally:
623
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
624
+ return KEYS.get(k, k)
625
+
626
+
627
+ def hint(*pairs):
628
+ return " " + " ".join(f"{fg('d9d4ee')}{k}{R} {DIM}{v}{R}" for k, v in pairs)
629
+
630
+
631
+ def value(o, k, selected):
632
+ v = o[k]
633
+ text = v
634
+ if isinstance(OPTIONS[k], dict):
635
+ text = fg(OPTIONS[k][v]) + "██" + R + " " + v
636
+ if k == "hatcolor" and o["hat"] == "none":
637
+ return f" {DIM}—{R}"
638
+ if selected:
639
+ return f"{ACCENT}◂{R} {text} {ACCENT}▸{R}"
640
+ return f" {text}"
641
+
642
+
643
+ def editor(o, saved):
644
+ keys, sel, toast = list(OPTIONS), 0, None
645
+ while True:
646
+ cols, rows = shutil.get_terminal_size()
647
+ if cols < 78 or rows < 23:
648
+ frame(["", f" Make the window a bit bigger ({cols}x{rows}, needs 78x23).", "",
649
+ hint(("esc", "back"))])
650
+ if key() in ("back", "quit"):
651
+ return "back"
652
+ continue
653
+ title = "".join(f"\x1b[1m{fg(ramp(i / 4))}{c}{R} " for i, c in enumerate("SHADE"))
654
+ panel = [" " + title, f" {DIM}faceless pixel avatars{R}", ""]
655
+ for i, k in enumerate(keys):
656
+ label = LABELS.get(k, k)
657
+ if i == sel:
658
+ panel.append(f"{ACCENT}›{R} {fg('ffffff')}{label:<11}{R}{value(o, k, True)}")
659
+ else:
660
+ panel.append(f" {DIM}{label:<11}{R}{value(o, k, False)}")
661
+ left = card(o)
662
+ lines = [""] + [left[i] + " " + (panel[i] if i < len(panel) else "") for i in range(len(left))]
663
+ lines.append(toast or hint(("↑↓", "choose"), ("←→", "change"), ("space", "shuffle"), ("⏎", "png"), ("g", "gif"), ("esc", "menu")))
664
+ frame(lines)
665
+ k, toast = key(), None
666
+ opts = list(OPTIONS[keys[sel]])
667
+ if k == "up":
668
+ sel = (sel - 1) % len(keys)
669
+ elif k == "down":
670
+ sel = (sel + 1) % len(keys)
671
+ elif k in ("left", "right"):
672
+ o[keys[sel]] = opts[(opts.index(o[keys[sel]]) + (1 if k == "right" else -1)) % len(opts)]
673
+ elif k == "space":
674
+ o.update(shuffle(o))
675
+ elif k in ("enter", "g"):
676
+ saved.append(save(o, animated=k == "g"))
677
+ toast = f" {OK}✓ saved{R} {saved[-1]} {DIM}keep going or esc{R}"
678
+ elif k in ("back", "quit"):
679
+ return k
680
+
681
+
682
+ def splash():
683
+ grid = wordmark_grid()
684
+ w = len(grid[0])
685
+ for f in range(1, 13):
686
+ t = 1 - (1 - f / 12) ** 3 # ease-out: fast start, gentle stop
687
+ cut = int(w * t)
688
+ frame(["", ""] + [" " + l for l in halfblocks([row[:cut] for row in grid], 2)])
689
+ time.sleep(0.016)
690
+
691
+
692
+ def menu():
693
+ saved, items, sel = [], ["create", "surprise me", "agent prompt", "quit"], 0
694
+ desc = {"create": "build your avatar, piece by piece", "surprise me": "start from a random look",
695
+ "agent prompt": "let an AI design one for you", "quit": ""}
696
+ sys.stdout.write("\x1b[?1049h\x1b[?25l")
697
+ try:
698
+ splash()
699
+ while True:
700
+ lines = ["", ""] + [" " + l for l in halfblocks(wordmark_grid(), 2)]
701
+ lines += ["", f" {DIM}faceless pixel avatars · no eyes, no nose, no mouth{R}", ""]
702
+ for i, it in enumerate(items):
703
+ if i == sel:
704
+ lines.append(f" {ACCENT}›{R} {fg('ffffff')}{it:<14}{R}{DIM}{desc[it]}{R}")
705
+ else:
706
+ lines.append(f" {DIM}{it}{R}")
707
+ lines += ["", hint(("↑↓", "choose"), ("⏎", "open"), ("q", "quit"))]
708
+ frame(lines)
709
+ k = key()
710
+ if k == "up":
711
+ sel = (sel - 1) % len(items)
712
+ elif k == "down":
713
+ sel = (sel + 1) % len(items)
714
+ elif k in ("quit", "back") or (k == "enter" and items[sel] == "quit"):
715
+ break
716
+ elif k == "enter" and items[sel] == "agent prompt":
717
+ sys.stdout.write("\x1b[?25h\x1b[?1049l")
718
+ return agent()
719
+ elif k == "enter":
720
+ o = dict(DEFAULT) if items[sel] == "create" else shuffle(DEFAULT)
721
+ if editor(o, saved) == "quit":
722
+ break
723
+ except KeyboardInterrupt:
724
+ pass
725
+ sys.stdout.write("\x1b[?25h\x1b[?1049l")
726
+ for name in saved:
727
+ print(f"{OK}✓{R} {name}")
728
+
729
+
730
+ # ── commands ─────────────────────────────────────────────────────────────────
731
+
732
+ def parse(words, o):
733
+ for w in words:
734
+ w = w.lower() + ("px" if w.isdigit() else "")
735
+ if w.startswith("@"):
736
+ try:
737
+ o.update(decode(w[1:]))
738
+ except ValueError:
739
+ sys.exit(f"shade: '{w}' is not an avatar code")
740
+ continue
741
+ if "=" in w:
742
+ k, v = w.split("=", 1)
743
+ k = {"background": "bg", "color": "topcolor"}.get(k, k)
744
+ if k not in OPTIONS or v not in OPTIONS[k]:
745
+ sys.exit(f"shade: unknown '{w}'. Run: shade help")
746
+ o[k] = v
747
+ continue
748
+ hits = [k for k, v in OPTIONS.items() if w in v]
749
+ if not hits:
750
+ sys.exit(f"shade: unknown word '{w}'. Run: shade help")
751
+ if len(hits) > 1:
752
+ sys.exit(f"shade: '{w}' fits several groups, say which: " + ", ".join(f"{k}={w}" for k in hits))
753
+ o[hits[0]] = w
754
+ return o
755
+
756
+
757
+ def show(o, name):
758
+ print("\n".join(" " + l for l in halfblocks(render(o))) + f"\n\n {OK}✓ saved{R} {name} {DIM}@{encode(o)}{R}")
759
+
760
+
761
+ def draw(path, size="400px"):
762
+ """Render a text grid: 'X #rrggbb' lines set colors, other lines are pixels."""
763
+ colors, rows = {".": None}, []
764
+ for line in open(path).read().splitlines():
765
+ parts = line.split()
766
+ if len(parts) == 2 and len(parts[0]) == 1:
767
+ colors[parts[0]] = parts[1].lstrip("#").lower()
768
+ elif len(parts) == 1:
769
+ rows.append(parts[0])
770
+ if not rows or len({len(r) for r in rows}) > 1:
771
+ sys.exit(f"shade: {path}: grid lines must all have the same length")
772
+ if bad := [k for k, v in colors.items() if v is not None and not re.fullmatch(r"[0-9a-f]{6}", v)]:
773
+ sys.exit(f"shade: {path}: bad color for {' '.join(bad)}. Use a hex color like: X #ff0000")
774
+ if missing := set("".join(rows)) - set(colors):
775
+ sys.exit(f"shade: {path}: no color for {' '.join(sorted(missing))}. Add a line like: X #ff0000")
776
+ grid = [[colors[c] for c in r] for r in rows]
777
+ out = os.path.splitext(os.path.basename(path))[0] + ".png"
778
+ with open(out, "wb") as f:
779
+ f.write(png(grid, max(1, int(size[:-2]) // max(len(rows), len(rows[0])))))
780
+ print("\n".join(" " + l for l in halfblocks(grid)) + f"\n\n {OK}✓ saved{R} {out}")
781
+
782
+
783
+ def groups():
784
+ return "\n".join(f" {k:<10} {' '.join(v)}" for k, v in OPTIONS.items())
785
+
786
+
787
+ AGENT_PROMPT = f"""\
788
+ Design a faceless pixel avatar with `shade`, a terminal tool.
789
+
790
+ House rule: no eyes, no nose, no mouth. Eyes are always covered and faces stay blank.
791
+
792
+ Option A (best): reply with one command made of words, one per group, any order.
793
+ Words that exist in several groups need group=value (example: topcolor=navy).
794
+ {groups()}
795
+ Example: shade curly haircolor=pink visor lens=blue hoodie topcolor=navy bg=night stars
796
+
797
+ Option B: draw it pixel by pixel. Reply with a text file in one code block:
798
+ - color lines: one character, a space, a hex color, like `K #0d0d14`
799
+ - then the grid: one character per pixel, no spaces, every line the same length
800
+ - `.` is transparent. Aim for 32x32. Respect the house rule.
801
+ I render it with: shade draw art.txt
802
+ """
803
+
804
+
805
+ def clipboard():
806
+ """Absolute path of the system clipboard tool and its encoding. Never resolved from the working folder."""
807
+ if os.name == "nt":
808
+ return os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "clip.exe"), [], "utf-16"
809
+ for cmd, *args in (["pbcopy"], ["wl-copy"], ["xclip", "-selection", "clipboard"]):
810
+ path = shutil.which(cmd)
811
+ if path and os.path.isabs(path):
812
+ return path, args, "utf-8"
813
+ return None, [], ""
814
+
815
+
816
+ def agent():
817
+ path, args, encoding = clipboard()
818
+ if path and os.path.exists(path):
819
+ import subprocess
820
+ subprocess.run([path, *args], input=AGENT_PROMPT.encode(encoding))
821
+ print(f"{OK}✓ copied to clipboard.{R} Paste it to your AI.\n")
822
+ print(AGENT_PROMPT)
823
+
824
+
825
+ def windows_console():
826
+ """Turn on UTF-8 output and ANSI colors, which older Windows consoles leave off."""
827
+ sys.stdout.reconfigure(encoding="utf-8")
828
+ import ctypes
829
+ kernel = ctypes.windll.kernel32
830
+ handle, mode = kernel.GetStdHandle(-11), ctypes.c_uint32()
831
+ if kernel.GetConsoleMode(handle, ctypes.byref(mode)):
832
+ kernel.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
833
+
834
+
835
+ HELP = f"""\
836
+ shade: faceless pixel avatars
837
+
838
+ shade open the studio
839
+ shade curly visor hoodie make one now, words in any order
840
+ shade topcolor=navy bg=night colors take group=value
841
+ shade random surprise me (add words to lock some)
842
+ shade curly visor gif animated GIF instead of a PNG
843
+ shade @code rebuild an avatar from its code
844
+ shade draw art.txt render your own grid
845
+ shade agent prompt that lets an AI design one
846
+
847
+ groups:
848
+ {groups()}
849
+ """
850
+
851
+
852
+ def main():
853
+ if os.name == "nt":
854
+ windows_console()
855
+ args = sys.argv[1:]
856
+ if not args:
857
+ return menu() if sys.stdin.isatty() and sys.stdout.isatty() else print(HELP)
858
+ cmd = args[0]
859
+ if cmd in ("version", "-v", "--version"):
860
+ print(f"shade {__version__}")
861
+ elif cmd in ("help", "-h", "--help"):
862
+ print(HELP)
863
+ elif cmd == "agent":
864
+ agent()
865
+ elif cmd == "draw" and len(args) > 1:
866
+ draw(args[1], parse(args[2:], dict(DEFAULT))["size"])
867
+ else:
868
+ animated = "gif" in args
869
+ args = [a for a in args if a != "gif"]
870
+ o = parse(args[1:], shuffle(DEFAULT)) if cmd == "random" else parse(args, dict(DEFAULT))
871
+ show(o, save(o, animated))
872
+
873
+
874
+ if __name__ == "__main__":
875
+ main()
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: shadepix
3
+ Version: 0.3.0
4
+ Summary: Faceless pixel avatars from your terminal. Zero dependencies.
5
+ Author: Bastien Cochet
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bstcoc/shade
8
+ Keywords: pixel-art,avatar,cli,terminal,generator
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ <p align="center">
15
+ <img src="assets/wordmark.png" width="420" alt="shade">
16
+ </p>
17
+
18
+ <p align="center">
19
+ <b>Faceless pixel avatars from your terminal.</b><br>
20
+ One Python file. Zero dependencies. Every face stays blank.<br><br>
21
+ <a href="https://bstcoc.github.io/shade/"><b>Try it in your browser →</b></a>
22
+ </p>
23
+
24
+ <p align="center">
25
+ <img src="assets/examples.png" alt="Avatars made with shade">
26
+ </p>
27
+
28
+ ## Why
29
+
30
+ Most avatar makers draw a face. shade draws everything else: hair, beard, hat, clothes, background.
31
+ Eyes are always covered (shades, visor, goggles, a band), and there is no nose and no mouth.
32
+ You get an avatar that feels like you without showing a face.
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ uv tool install git+https://github.com/bstcoc/shade
38
+ # or: pipx install git+https://github.com/bstcoc/shade
39
+ ```
40
+
41
+ Needs Python 3.10+ and nothing else. Works on macOS, Linux and Windows. The command is `shade`.
42
+
43
+ ## Use it
44
+
45
+ Run `shade` to open the studio:
46
+
47
+ <p align="center"><img src="assets/demo.gif" width="760" alt="The shade studio"></p>
48
+
49
+ - `↑` `↓` pick a setting, `←` `→` change it, and the preview updates live
50
+ - `space` shuffles the look and keeps your size, shape and detail level
51
+ - `⏎` saves a PNG and `g` saves an animated GIF, then you can keep going
52
+
53
+ You can also make an avatar without the studio:
54
+
55
+ ```sh
56
+ shade curly visor hoodie # words in any order
57
+ shade topcolor=navy bg=night stars # colors take group=value
58
+ shade random beanie # random look, with a beanie
59
+ shade curly visor gif # animated GIF: a light streak sweeps the lenses
60
+ shade @egtuw4yi # rebuild an avatar from its code
61
+ shade help # every group and value
62
+ ```
63
+
64
+ Every avatar has a short code like `@egtuw4yi`. Share it and anyone can rebuild the same avatar.
65
+
66
+ ### What you can change
67
+
68
+ 17 settings: head shape, skin, hair, hair color, beard, hat, hat color, eyewear, lens color, top, top color, extra, background, pattern, detail level (`rich`, `shaded`, `flat`), shape (`rounded`, `circle`, `square`) and size (200, 400 or 1200 px).
69
+
70
+ ## Use it from an agent
71
+
72
+ shade ships a skill for Claude Code. Install it once and ask for a pixel avatar in plain words:
73
+
74
+ ```sh
75
+ mkdir -p ~/.claude/skills/shade
76
+ curl -fsSL -o ~/.claude/skills/shade/SKILL.md https://raw.githubusercontent.com/bstcoc/shade/main/skills/shade/SKILL.md
77
+ ```
78
+
79
+ ## Let any AI design one
80
+
81
+ ```sh
82
+ shade agent
83
+ ```
84
+
85
+ This copies a ready-made prompt to your clipboard. Paste it to any AI and it replies with a `shade` command, or with a pixel grid you render using `shade draw`.
86
+
87
+ ## Draw your own
88
+
89
+ A grid is a text file. A line like `K #0d0d14` sets a color, every other line is a row of pixels, and `.` is transparent.
90
+
91
+ ```
92
+ K #0d0d14
93
+ r #ff0000
94
+ .K.
95
+ KrK
96
+ .K.
97
+ ```
98
+
99
+ ```sh
100
+ shade draw art.txt
101
+ ```
102
+
103
+ ## Releases
104
+
105
+ Every merged release PR publishes a new version to PyPI and GitHub, and the [web demo](https://bstcoc.github.io/shade/) picks it up with its notes. See [CHANGELOG.md](CHANGELOG.md).
106
+
107
+ ## License
108
+
109
+ [MIT](LICENSE)
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ shade.py
5
+ shadepix.egg-info/PKG-INFO
6
+ shadepix.egg-info/SOURCES.txt
7
+ shadepix.egg-info/dependency_links.txt
8
+ shadepix.egg-info/entry_points.txt
9
+ shadepix.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shade = shade:main
@@ -0,0 +1 @@
1
+ shade