sepia-cli 0.1.0__py3-none-any.whl
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.
- sepia/__init__.py +3 -0
- sepia/__main__.py +3 -0
- sepia/ascii_art.py +368 -0
- sepia/cli.py +58 -0
- sepia/config.py +105 -0
- sepia/director.py +162 -0
- sepia/ipc.py +169 -0
- sepia/launcher.py +141 -0
- sepia/painter.py +326 -0
- sepia/prompts/director.md +17 -0
- sepia/prompts/request.md +2 -0
- sepia/py.typed +0 -0
- sepia/raster.py +33 -0
- sepia/repl.py +340 -0
- sepia/scene.py +54 -0
- sepia/ui.py +220 -0
- sepia/viewer.py +167 -0
- sepia_cli-0.1.0.dist-info/METADATA +204 -0
- sepia_cli-0.1.0.dist-info/RECORD +22 -0
- sepia_cli-0.1.0.dist-info/WHEEL +4 -0
- sepia_cli-0.1.0.dist-info/entry_points.txt +3 -0
- sepia_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
sepia/__init__.py
ADDED
sepia/__main__.py
ADDED
sepia/ascii_art.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Turn RGB images into colored symbol art."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import numpy.typing as npt
|
|
8
|
+
from rich.style import Style
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from sepia.raster import RGBImage
|
|
12
|
+
|
|
13
|
+
CELL_WIDTH = 8
|
|
14
|
+
CELL_HEIGHT = 16
|
|
15
|
+
"""Pixels rendered per terminal cell. Terminal cells are about twice as tall as wide."""
|
|
16
|
+
|
|
17
|
+
ArtStyle = Literal["ink", "fill"]
|
|
18
|
+
"""`ink` colors the symbols on the terminal's own background (made for dark themes).
|
|
19
|
+
`fill` also paints each cell's background, which shows the picture on any theme."""
|
|
20
|
+
|
|
21
|
+
FloatArray = npt.NDArray[np.float32]
|
|
22
|
+
|
|
23
|
+
Charset = Literal["classic", "symbols"]
|
|
24
|
+
"""`classic` is the full ASCII-art alphabet (letters, digits, symbols), which gives the
|
|
25
|
+
finest shading for photographs. `symbols` uses punctuation only."""
|
|
26
|
+
|
|
27
|
+
# Characters with the share of a cell their ink covers, averaged over Cascadia Mono
|
|
28
|
+
# (the Windows Terminal default) and Consolas, each spaced at least 0.015 apart so that
|
|
29
|
+
# neighbors in a ramp look different.
|
|
30
|
+
_CLASSIC: tuple[tuple[str, float], ...] = (
|
|
31
|
+
(" ", 0.000),
|
|
32
|
+
("`", 0.076),
|
|
33
|
+
("'", 0.133),
|
|
34
|
+
("-", 0.157),
|
|
35
|
+
(",", 0.177),
|
|
36
|
+
("_", 0.200),
|
|
37
|
+
("^", 0.246),
|
|
38
|
+
('"', 0.262),
|
|
39
|
+
("!", 0.290),
|
|
40
|
+
("<", 0.347),
|
|
41
|
+
("+", 0.365),
|
|
42
|
+
("?", 0.410),
|
|
43
|
+
("|", 0.427),
|
|
44
|
+
("c", 0.443),
|
|
45
|
+
("(", 0.475),
|
|
46
|
+
("i", 0.491),
|
|
47
|
+
("}", 0.507),
|
|
48
|
+
("n", 0.529),
|
|
49
|
+
("f", 0.545),
|
|
50
|
+
("o", 0.575),
|
|
51
|
+
("a", 0.600),
|
|
52
|
+
("X", 0.622),
|
|
53
|
+
("w", 0.651),
|
|
54
|
+
("b", 0.695),
|
|
55
|
+
("O", 0.734),
|
|
56
|
+
("%", 0.763),
|
|
57
|
+
("8", 0.792),
|
|
58
|
+
("0", 0.812),
|
|
59
|
+
("W", 0.839),
|
|
60
|
+
("Q", 0.875),
|
|
61
|
+
("@", 1.000),
|
|
62
|
+
)
|
|
63
|
+
# Punctuation only, measured the same way. Directional glyphs stay out so that a
|
|
64
|
+
# texture never reads as an outline.
|
|
65
|
+
_SYMBOLS: tuple[tuple[str, float], ...] = (
|
|
66
|
+
(" ", 0.00),
|
|
67
|
+
(".", 0.08),
|
|
68
|
+
("'", 0.15),
|
|
69
|
+
(":", 0.16),
|
|
70
|
+
(";", 0.24),
|
|
71
|
+
("^", 0.27),
|
|
72
|
+
("!", 0.29),
|
|
73
|
+
("~", 0.30),
|
|
74
|
+
("+", 0.39),
|
|
75
|
+
("*", 0.45),
|
|
76
|
+
("(", 0.55),
|
|
77
|
+
("%", 0.80),
|
|
78
|
+
("#", 0.81),
|
|
79
|
+
("&", 0.87),
|
|
80
|
+
("@", 1.00),
|
|
81
|
+
)
|
|
82
|
+
RAMPS: dict[Charset, tuple[tuple[str, float], ...]] = {
|
|
83
|
+
"classic": _CLASSIC,
|
|
84
|
+
"symbols": _SYMBOLS,
|
|
85
|
+
}
|
|
86
|
+
_RAMP_ARRAYS = {
|
|
87
|
+
name: (np.array([c for c, _ in ramp]), np.array([d for _, d in ramp]))
|
|
88
|
+
for name, ramp in RAMPS.items()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
EDGE_STRENGTH = 0.35
|
|
92
|
+
"""Sobel magnitude (blurred luma) above which a pixel counts as part of an edge."""
|
|
93
|
+
EDGE_MIN_PIXELS = 5
|
|
94
|
+
"""Thinned edge pixels a cell needs before it is drawn as an outline."""
|
|
95
|
+
CONTRAST_STRETCH = 0.6
|
|
96
|
+
"""How far luminance is stretched toward the full ramp (0 keeps, 1 stretches fully)."""
|
|
97
|
+
SATURATION_BOOST = 1.2
|
|
98
|
+
"""Thin glyphs show less color than a filled cell, so ink colors are pushed a little."""
|
|
99
|
+
INK_FLOOR = 0.55
|
|
100
|
+
"""Lowest brightness of an ink color (its strongest channel, 0..1). Symbol density
|
|
101
|
+
already shows how bright a cell is, so the color only has to carry its hue; without a
|
|
102
|
+
floor, dark surfaces print as nearly invisible symbols."""
|
|
103
|
+
DENSITY_GAMMA = 0.5
|
|
104
|
+
"""Curve from luminance to symbol density. Below 1 it lifts dark surfaces off the
|
|
105
|
+
background so they read as texture instead of empty space."""
|
|
106
|
+
FILL_BACKGROUND = 0.55
|
|
107
|
+
"""Brightness of the painted background in `fill` style, relative to the cell color."""
|
|
108
|
+
FILL_HIGHLIGHT = 0.35
|
|
109
|
+
"""How far `fill` symbols are mixed toward white so they stand out on their own cell."""
|
|
110
|
+
DITHER = 0.05
|
|
111
|
+
"""Ordered-dither amplitude added to mid-tone luminance before a symbol is picked. It
|
|
112
|
+
turns smooth gradients into mixed symbols instead of one-symbol bands."""
|
|
113
|
+
|
|
114
|
+
# 4x4 Bayer matrix, centered on zero so dithering does not shift overall brightness.
|
|
115
|
+
_BAYER = (
|
|
116
|
+
np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]], np.float32)
|
|
117
|
+
+ 0.5
|
|
118
|
+
) / 16 - 0.5
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, eq=False)
|
|
122
|
+
class SymbolArt:
|
|
123
|
+
"""A grid of symbols with a foreground (and optional background) color per cell."""
|
|
124
|
+
|
|
125
|
+
chars: npt.NDArray[np.str_]
|
|
126
|
+
fg: RGBImage
|
|
127
|
+
bg: RGBImage | None
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def rows(self) -> int:
|
|
131
|
+
return int(self.chars.shape[0])
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def cols(self) -> int:
|
|
135
|
+
return int(self.chars.shape[1])
|
|
136
|
+
|
|
137
|
+
def lines(self) -> list[str]:
|
|
138
|
+
return ["".join(row) for row in self.chars]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def image_to_art(
|
|
142
|
+
pixels: RGBImage,
|
|
143
|
+
cols: int,
|
|
144
|
+
rows: int,
|
|
145
|
+
style: ArtStyle = "ink",
|
|
146
|
+
charset: Charset = "classic",
|
|
147
|
+
edges: bool = False,
|
|
148
|
+
) -> SymbolArt:
|
|
149
|
+
"""Convert `pixels` into `cols` x `rows` cells of colored symbols.
|
|
150
|
+
|
|
151
|
+
Each cell averages the pixels under it, so `pixels` should be rendered larger than
|
|
152
|
+
the grid (see `CELL_WIDTH` and `CELL_HEIGHT`). Leftover pixels at the right and
|
|
153
|
+
bottom edges are dropped.
|
|
154
|
+
"""
|
|
155
|
+
if cols <= 0 or rows <= 0:
|
|
156
|
+
raise ValueError(f"grid must be positive, got {cols}x{rows}")
|
|
157
|
+
height, width = pixels.shape[:2]
|
|
158
|
+
cell_h, cell_w = height // rows, width // cols
|
|
159
|
+
if cell_h == 0 or cell_w == 0:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"image {width}x{height} is smaller than the {cols}x{rows} grid"
|
|
162
|
+
)
|
|
163
|
+
srgb = pixels[: rows * cell_h, : cols * cell_w].astype(np.float32) / 255.0
|
|
164
|
+
|
|
165
|
+
linear = _srgb_to_linear(srgb)
|
|
166
|
+
cell_color = _linear_to_srgb(_cell_mean(linear, rows, cell_h, cols, cell_w))
|
|
167
|
+
glow = _linear_to_srgb(_bright_mean(linear, rows, cell_h, cols, cell_w))
|
|
168
|
+
density = _stretch(_luma(cell_color)) ** DENSITY_GAMMA
|
|
169
|
+
ramp_chars, ramp_density = _RAMP_ARRAYS[charset]
|
|
170
|
+
chars = ramp_chars[_ramp_index(_dither(density), ramp_density)]
|
|
171
|
+
if edges:
|
|
172
|
+
edge_chars = _edge_chars(_luma(srgb), rows, cell_h, cols, cell_w)
|
|
173
|
+
chars = np.where(edge_chars != "", edge_chars, chars)
|
|
174
|
+
|
|
175
|
+
if style == "fill":
|
|
176
|
+
fg = glow + (1.0 - glow) * FILL_HIGHLIGHT
|
|
177
|
+
return SymbolArt(chars, _to_bytes(fg), _to_bytes(cell_color * FILL_BACKGROUND))
|
|
178
|
+
return SymbolArt(chars, _to_bytes(_ink(glow)), None)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def to_text(art: SymbolArt) -> Text:
|
|
182
|
+
"""Build a rich `Text` that prints the art, one terminal line per row."""
|
|
183
|
+
text = Text(no_wrap=True, overflow="crop", end="")
|
|
184
|
+
styles: dict[tuple[int, ...], Style] = {}
|
|
185
|
+
for row in range(art.rows):
|
|
186
|
+
for col in range(art.cols):
|
|
187
|
+
fg = tuple(int(v) for v in art.fg[row, col])
|
|
188
|
+
bg = () if art.bg is None else tuple(int(v) for v in art.bg[row, col])
|
|
189
|
+
key = fg + bg
|
|
190
|
+
style = styles.get(key)
|
|
191
|
+
if style is None:
|
|
192
|
+
style = Style(color=_hex(fg), bgcolor=_hex(bg) if bg else None)
|
|
193
|
+
styles[key] = style
|
|
194
|
+
text.append(str(art.chars[row, col]), style)
|
|
195
|
+
if row < art.rows - 1:
|
|
196
|
+
text.append("\n")
|
|
197
|
+
return text
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _srgb_to_linear(c: FloatArray) -> FloatArray:
|
|
201
|
+
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _linear_to_srgb(c: FloatArray) -> FloatArray:
|
|
205
|
+
c = np.clip(c, 0.0, 1.0)
|
|
206
|
+
return np.where(c <= 0.0031308, c * 12.92, 1.055 * c ** (1 / 2.4) - 0.055)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _luma(srgb: FloatArray) -> FloatArray:
|
|
210
|
+
weights = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
|
|
211
|
+
result: FloatArray = srgb @ weights
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _cell_mean(
|
|
216
|
+
values: FloatArray, rows: int, cell_h: int, cols: int, cell_w: int
|
|
217
|
+
) -> FloatArray:
|
|
218
|
+
shaped = values.reshape(rows, cell_h, cols, cell_w, *values.shape[2:])
|
|
219
|
+
result: FloatArray = shaped.mean(axis=(1, 3))
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _bright_mean(
|
|
224
|
+
linear: FloatArray, rows: int, cell_h: int, cols: int, cell_w: int
|
|
225
|
+
) -> FloatArray:
|
|
226
|
+
"""Cell color weighted toward its brightest pixels.
|
|
227
|
+
|
|
228
|
+
A symbol's ink stands for the light in its cell, so a small lit window or star
|
|
229
|
+
should give the symbol its color instead of being averaged into the dark around
|
|
230
|
+
it. Weighting by squared luminance does that and leaves flat cells unchanged.
|
|
231
|
+
"""
|
|
232
|
+
weight = (_luma(linear) ** 2 + 1e-6)[..., None]
|
|
233
|
+
total = _cell_mean(linear * weight, rows, cell_h, cols, cell_w)
|
|
234
|
+
result: FloatArray = total / _cell_mean(weight, rows, cell_h, cols, cell_w)
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _stretch(luma: FloatArray) -> FloatArray:
|
|
239
|
+
low, high = (float(v) for v in np.percentile(luma, [2, 98]))
|
|
240
|
+
if high - low < 1e-3:
|
|
241
|
+
return luma
|
|
242
|
+
stretched = np.clip((luma - low) / (high - low), 0.0, 1.0)
|
|
243
|
+
result: FloatArray = luma + (stretched - luma) * CONTRAST_STRETCH
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _dither(luma: FloatArray) -> FloatArray:
|
|
248
|
+
"""Add the Bayer pattern, fading it out toward pure black and pure white."""
|
|
249
|
+
rows, cols = luma.shape
|
|
250
|
+
pattern = np.tile(_BAYER, (rows // 4 + 1, cols // 4 + 1))[:rows, :cols]
|
|
251
|
+
midtone = 4.0 * luma * (1.0 - luma)
|
|
252
|
+
result: FloatArray = (luma + pattern * 2.0 * DITHER * midtone).astype(np.float32)
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _ramp_index(
|
|
257
|
+
target: FloatArray, density: npt.NDArray[np.float64]
|
|
258
|
+
) -> npt.NDArray[np.intp]:
|
|
259
|
+
"""Index of the ramp character whose ink density is closest to each target."""
|
|
260
|
+
index: npt.NDArray[np.intp] = np.abs(target[..., None] - density).argmin(axis=-1)
|
|
261
|
+
return index
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _edge_chars(
|
|
265
|
+
luma: FloatArray, rows: int, cell_h: int, cols: int, cell_w: int
|
|
266
|
+
) -> npt.NDArray[np.str_]:
|
|
267
|
+
"""Outline glyph per cell (`| / - _ \\`), or "" for cells without a strong edge."""
|
|
268
|
+
blurred = _blur(luma)
|
|
269
|
+
gx, gy = _sobel(blurred)
|
|
270
|
+
magnitude = np.hypot(gx, gy)
|
|
271
|
+
# Gradient angle folded into [0, 180): 0 means brightness changes left to right, so
|
|
272
|
+
# the edge itself runs vertically. Image rows grow downward.
|
|
273
|
+
angle = np.degrees(np.arctan2(gy, gx)) % 180.0
|
|
274
|
+
bins = ((angle + 22.5) // 45.0).astype(np.intp) % 4 # 0: | 1: / 2: - 3: \
|
|
275
|
+
strong = (magnitude > EDGE_STRENGTH) & _ridge(magnitude, bins)
|
|
276
|
+
|
|
277
|
+
def per_cell(mask: npt.NDArray[np.bool_]) -> FloatArray:
|
|
278
|
+
return _cell_mean(mask.astype(np.float64), rows, cell_h, cols, cell_w)
|
|
279
|
+
|
|
280
|
+
coverage = np.stack([per_cell(strong & (bins == b)) for b in range(4)], axis=-1)
|
|
281
|
+
best = coverage.argmax(axis=-1)
|
|
282
|
+
is_edge = coverage.sum(axis=-1) * cell_h * cell_w >= EDGE_MIN_PIXELS
|
|
283
|
+
|
|
284
|
+
# A horizontal edge in the lower part of its cell reads better as "_" than "-".
|
|
285
|
+
row_pos = np.broadcast_to(
|
|
286
|
+
np.arange(luma.shape[0], dtype=np.float64)[:, None], luma.shape
|
|
287
|
+
)
|
|
288
|
+
row_in_cell = (row_pos % cell_h) / max(cell_h - 1, 1)
|
|
289
|
+
horizontal = strong & (bins == 2)
|
|
290
|
+
count = per_cell(horizontal)
|
|
291
|
+
weighted = _cell_mean(row_in_cell * horizontal, rows, cell_h, cols, cell_w)
|
|
292
|
+
mean_pos = np.divide(weighted, count, out=np.zeros_like(count), where=count > 0)
|
|
293
|
+
glyphs = np.array(["|", "/", "-", "\\"])[best]
|
|
294
|
+
glyphs = np.where((best == 2) & (mean_pos > 0.7), "_", glyphs)
|
|
295
|
+
return np.where(is_edge, glyphs, "")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _ridge(magnitude: FloatArray, bins: npt.NDArray[np.intp]) -> npt.NDArray[np.bool_]:
|
|
299
|
+
"""Keep only pixels that peak across their edge (non-maximum suppression).
|
|
300
|
+
|
|
301
|
+
Blurring smears an edge over several pixels, which would mark the cells on both
|
|
302
|
+
sides of it. Thinning to a one-pixel ridge puts each edge in exactly one cell; the
|
|
303
|
+
`>=` / `>` pair breaks ties the same way every time.
|
|
304
|
+
"""
|
|
305
|
+
height, width = magnitude.shape
|
|
306
|
+
padded = np.pad(magnitude, 1)
|
|
307
|
+
|
|
308
|
+
def neighbor(dy: int, dx: int) -> FloatArray:
|
|
309
|
+
result: FloatArray = padded[1 + dy : 1 + dy + height, 1 + dx : 1 + dx + width]
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
# Neighbors along the gradient, i.e. across the edge, for each direction bin.
|
|
313
|
+
across = {
|
|
314
|
+
0: ((0, -1), (0, 1)),
|
|
315
|
+
1: ((-1, -1), (1, 1)),
|
|
316
|
+
2: ((-1, 0), (1, 0)),
|
|
317
|
+
3: ((-1, 1), (1, -1)),
|
|
318
|
+
}
|
|
319
|
+
keep = np.zeros(magnitude.shape, dtype=bool)
|
|
320
|
+
for direction, (before, after) in across.items():
|
|
321
|
+
peak = (magnitude >= neighbor(*before)) & (magnitude > neighbor(*after))
|
|
322
|
+
keep |= (bins == direction) & peak
|
|
323
|
+
return keep
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _blur(image: FloatArray, sigma: float = 1.5) -> FloatArray:
|
|
327
|
+
radius = int(3 * sigma)
|
|
328
|
+
offsets = np.arange(-radius, radius + 1)
|
|
329
|
+
kernel = np.exp(-(offsets**2) / (2 * sigma**2)).astype(np.float32)
|
|
330
|
+
kernel /= kernel.sum()
|
|
331
|
+
for axis in (0, 1):
|
|
332
|
+
pad = [(radius, radius) if a == axis else (0, 0) for a in (0, 1)]
|
|
333
|
+
padded = np.pad(image, pad, "edge")
|
|
334
|
+
length = image.shape[axis]
|
|
335
|
+
blurred = np.zeros_like(image)
|
|
336
|
+
for i, weight in enumerate(kernel):
|
|
337
|
+
blurred += weight * np.take(padded, np.arange(i, i + length), axis=axis)
|
|
338
|
+
image = blurred
|
|
339
|
+
return image
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _sobel(image: FloatArray) -> tuple[FloatArray, FloatArray]:
|
|
343
|
+
p = np.pad(image, 1, "edge")
|
|
344
|
+
top_left, top, top_right = p[:-2, :-2], p[:-2, 1:-1], p[:-2, 2:]
|
|
345
|
+
left, right = p[1:-1, :-2], p[1:-1, 2:]
|
|
346
|
+
bottom_left, bottom, bottom_right = p[2:, :-2], p[2:, 1:-1], p[2:, 2:]
|
|
347
|
+
gx = (top_right + 2 * right + bottom_right) - (top_left + 2 * left + bottom_left)
|
|
348
|
+
gy = (bottom_left + 2 * bottom + bottom_right) - (top_left + 2 * top + top_right)
|
|
349
|
+
return gx, gy
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _ink(color: FloatArray) -> FloatArray:
|
|
353
|
+
gray = _luma(color)[..., None]
|
|
354
|
+
saturated = np.clip(gray + (color - gray) * SATURATION_BOOST, 0.0, 1.0)
|
|
355
|
+
value = saturated.max(axis=-1, keepdims=True)
|
|
356
|
+
lifted = INK_FLOOR + (1.0 - INK_FLOOR) * value
|
|
357
|
+
scale = np.divide(lifted, value, out=np.ones_like(value), where=value > 0.02)
|
|
358
|
+
result: FloatArray = np.clip(saturated * scale, 0.0, 1.0).astype(np.float32)
|
|
359
|
+
return result
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _to_bytes(color: FloatArray) -> RGBImage:
|
|
363
|
+
return np.clip(np.rint(color * 255.0), 0, 255).astype(np.uint8)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _hex(rgb: tuple[int, ...]) -> str:
|
|
367
|
+
red, green, blue = rgb
|
|
368
|
+
return f"#{red:02x}{green:02x}{blue:02x}"
|
sepia/cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Command-line entry point for `sepia`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sepia import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="sepia",
|
|
15
|
+
description=(
|
|
16
|
+
"Describe a picture and watch it appear as colored text in a second "
|
|
17
|
+
"terminal window."
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("--version", action="version", version=f"sepia {__version__}")
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--download",
|
|
23
|
+
action="store_true",
|
|
24
|
+
help="download the local image model (about 16 GB) used when the API fails",
|
|
25
|
+
)
|
|
26
|
+
# Internal: the picture window runs `sepia --viewer <session file>`.
|
|
27
|
+
parser.add_argument("--viewer", type=Path, help=argparse.SUPPRESS)
|
|
28
|
+
return parser
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
32
|
+
_use_utf8()
|
|
33
|
+
args = build_parser().parse_args(argv)
|
|
34
|
+
if args.viewer is not None:
|
|
35
|
+
from sepia.viewer import run_viewer
|
|
36
|
+
|
|
37
|
+
return run_viewer(args.viewer)
|
|
38
|
+
if args.download:
|
|
39
|
+
from sepia.painter import PainterError, download_local_model
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
print(f"downloaded to {download_local_model()}")
|
|
43
|
+
except PainterError as exc:
|
|
44
|
+
print(exc, file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
return 0
|
|
47
|
+
from sepia.repl import run_repl
|
|
48
|
+
|
|
49
|
+
return run_repl()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _use_utf8() -> None:
|
|
53
|
+
"""Block and box glyphs need UTF-8 even where the console code page is not."""
|
|
54
|
+
for stream in (sys.stdout, sys.stderr):
|
|
55
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
56
|
+
if reconfigure is not None:
|
|
57
|
+
with contextlib.suppress(OSError, ValueError):
|
|
58
|
+
reconfigure(encoding="utf-8")
|
sepia/config.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Settings and API key lookup."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal, cast, get_args
|
|
10
|
+
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
from platformdirs import user_config_path
|
|
13
|
+
|
|
14
|
+
from sepia.ascii_art import ArtStyle, Charset
|
|
15
|
+
from sepia.painter import DEFAULT_IMAGE_MODEL
|
|
16
|
+
|
|
17
|
+
Effort = Literal["low", "medium", "high", "xhigh", "max"]
|
|
18
|
+
|
|
19
|
+
DEFAULT_MODEL = "claude-haiku-5-5"
|
|
20
|
+
DEFAULT_EFFORT: Effort = "low"
|
|
21
|
+
DEFAULT_STYLE: ArtStyle = "ink"
|
|
22
|
+
DEFAULT_CHARSET: Charset = "classic"
|
|
23
|
+
API_KEY_VAR = "ANTHROPIC_API_KEY"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigError(ValueError):
|
|
27
|
+
"""A setting has a value sepia cannot use."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Settings:
|
|
32
|
+
model: str = DEFAULT_MODEL
|
|
33
|
+
effort: Effort = DEFAULT_EFFORT
|
|
34
|
+
style: ArtStyle = DEFAULT_STYLE
|
|
35
|
+
charset: Charset = DEFAULT_CHARSET
|
|
36
|
+
image_model: str = DEFAULT_IMAGE_MODEL
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def config_dir() -> Path:
|
|
40
|
+
"""Per-user directory for sepia's key file, history and session files."""
|
|
41
|
+
return user_config_path("sepia", appauthor=False)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def user_env_file() -> Path:
|
|
45
|
+
return config_dir() / ".env"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_environment(cwd: Path | None = None) -> None:
|
|
49
|
+
"""Fill `os.environ` from `.env` files without overriding what is already set.
|
|
50
|
+
|
|
51
|
+
Lookup order: the real environment, then `.env` in the working directory (handy
|
|
52
|
+
while developing), then the `.env` in the user config directory.
|
|
53
|
+
"""
|
|
54
|
+
load_dotenv((cwd or Path.cwd()) / ".env", override=False)
|
|
55
|
+
load_dotenv(user_env_file(), override=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_settings(environ: Mapping[str, str] = os.environ) -> Settings:
|
|
59
|
+
model = environ.get("SEPIA_MODEL", "").strip() or DEFAULT_MODEL
|
|
60
|
+
image_model = environ.get("SEPIA_IMAGE_MODEL", "").strip() or DEFAULT_IMAGE_MODEL
|
|
61
|
+
effort = _choice(environ, "SEPIA_EFFORT", get_args(Effort), DEFAULT_EFFORT)
|
|
62
|
+
style = _choice(environ, "SEPIA_STYLE", get_args(ArtStyle), DEFAULT_STYLE)
|
|
63
|
+
charset = _choice(environ, "SEPIA_CHARSET", get_args(Charset), DEFAULT_CHARSET)
|
|
64
|
+
return Settings(
|
|
65
|
+
model=model,
|
|
66
|
+
effort=cast(Effort, effort),
|
|
67
|
+
style=cast(ArtStyle, style),
|
|
68
|
+
charset=cast(Charset, charset),
|
|
69
|
+
image_model=image_model,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def api_key(environ: Mapping[str, str] = os.environ) -> str | None:
|
|
74
|
+
return environ.get(API_KEY_VAR, "").strip() or None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def pollinations_key(environ: Mapping[str, str] = os.environ) -> str | None:
|
|
78
|
+
"""Optional key from enter.pollinations.ai: no logo and better models."""
|
|
79
|
+
return environ.get("POLLINATIONS_KEY", "").strip() or None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def save_api_key(key: str, path: Path | None = None) -> Path:
|
|
83
|
+
"""Store `key` in the user config `.env`, readable only by its owner."""
|
|
84
|
+
target = path or user_env_file()
|
|
85
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
kept: list[str] = []
|
|
87
|
+
if target.exists():
|
|
88
|
+
kept = [
|
|
89
|
+
line
|
|
90
|
+
for line in target.read_text(encoding="utf-8").splitlines()
|
|
91
|
+
if not line.startswith(f"{API_KEY_VAR}=")
|
|
92
|
+
]
|
|
93
|
+
target.write_text("\n".join([*kept, f"{API_KEY_VAR}={key.strip()}"]) + "\n")
|
|
94
|
+
with contextlib.suppress(OSError): # Windows ignores POSIX modes
|
|
95
|
+
target.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
96
|
+
return target
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _choice(
|
|
100
|
+
environ: Mapping[str, str], name: str, allowed: tuple[str, ...], default: str
|
|
101
|
+
) -> str:
|
|
102
|
+
value = environ.get(name, "").strip().lower() or default
|
|
103
|
+
if value not in allowed:
|
|
104
|
+
raise ConfigError(f"{name} must be one of {', '.join(allowed)}; got {value!r}")
|
|
105
|
+
return value
|