staticfx 2.0.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.

Potentially problematic release.


This version of staticfx might be problematic. Click here for more details.

Files changed (65) hide show
  1. staticfx/__init__.py +3 -0
  2. staticfx/__main__.py +5 -0
  3. staticfx/app/__init__.py +1 -0
  4. staticfx/app/app.py +335 -0
  5. staticfx/app/keymap.py +163 -0
  6. staticfx/app/player.py +143 -0
  7. staticfx/app/session.py +97 -0
  8. staticfx/cli/__init__.py +20 -0
  9. staticfx/cli/commands.py +293 -0
  10. staticfx/cli/parser.py +185 -0
  11. staticfx/core/__init__.py +1 -0
  12. staticfx/core/clock.py +30 -0
  13. staticfx/core/color.py +89 -0
  14. staticfx/core/frame.py +85 -0
  15. staticfx/core/glyphs.py +15 -0
  16. staticfx/core/keys.py +116 -0
  17. staticfx/core/renderer.py +84 -0
  18. staticfx/core/terminal.py +148 -0
  19. staticfx/effects/__init__.py +1 -0
  20. staticfx/effects/base.py +88 -0
  21. staticfx/effects/builtin/__init__.py +203 -0
  22. staticfx/effects/builtin/fields.py +404 -0
  23. staticfx/effects/builtin/geometry.py +77 -0
  24. staticfx/effects/builtin/particles.py +623 -0
  25. staticfx/effects/builtin/shapes.py +255 -0
  26. staticfx/effects/builtin/signals.py +298 -0
  27. staticfx/effects/builtin/simulations.py +439 -0
  28. staticfx/effects/builtin/terminal.py +362 -0
  29. staticfx/effects/curated.py +16 -0
  30. staticfx/effects/engine/__init__.py +11 -0
  31. staticfx/effects/engine/effect.py +267 -0
  32. staticfx/effects/engine/filters.py +115 -0
  33. staticfx/effects/engine/generate.py +203 -0
  34. staticfx/effects/engine/layers.py +396 -0
  35. staticfx/effects/engine/recipe.py +215 -0
  36. staticfx/effects/engine/schema.py +87 -0
  37. staticfx/effects/engine/warps.py +203 -0
  38. staticfx/effects/registry.py +103 -0
  39. staticfx/effects/spec.py +51 -0
  40. staticfx/integrations.py +48 -0
  41. staticfx/library/__init__.py +1 -0
  42. staticfx/library/config.py +122 -0
  43. staticfx/library/effects.py +159 -0
  44. staticfx/library/files.py +44 -0
  45. staticfx/library/legacy.py +180 -0
  46. staticfx/library/looks.py +134 -0
  47. staticfx/postfx.py +78 -0
  48. staticfx/py.typed +0 -0
  49. staticfx/themes.py +162 -0
  50. staticfx/ui/__init__.py +1 -0
  51. staticfx/ui/browser.py +269 -0
  52. staticfx/ui/dialogs.py +95 -0
  53. staticfx/ui/help.py +99 -0
  54. staticfx/ui/pickers.py +221 -0
  55. staticfx/ui/play.py +213 -0
  56. staticfx/ui/screen.py +39 -0
  57. staticfx/ui/settings.py +262 -0
  58. staticfx/ui/studio.py +433 -0
  59. staticfx/ui/widgets.py +196 -0
  60. staticfx-2.0.0.dist-info/METADATA +164 -0
  61. staticfx-2.0.0.dist-info/RECORD +65 -0
  62. staticfx-2.0.0.dist-info/WHEEL +5 -0
  63. staticfx-2.0.0.dist-info/entry_points.txt +2 -0
  64. staticfx-2.0.0.dist-info/licenses/LICENSE +21 -0
  65. staticfx-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,362 @@
1
+ """Retro computing: a boot log, decryption, a barcode scanner, circuit traces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+
8
+ from staticfx.core.color import clamp
9
+ from staticfx.core.frame import Frame
10
+ from staticfx.core.glyphs import HEX, tech_glyphs
11
+ from staticfx.effects.base import Effect
12
+ from staticfx.themes import Theme
13
+
14
+ SERVICES = (
15
+ "udev",
16
+ "journald",
17
+ "network",
18
+ "dbus",
19
+ "cryptsetup",
20
+ "sshd",
21
+ "firewall",
22
+ "audio daemon",
23
+ "display manager",
24
+ "scheduler",
25
+ "time sync",
26
+ "kernel modules",
27
+ "power manager",
28
+ "entropy pool",
29
+ "mesh uplink",
30
+ "neural cache",
31
+ )
32
+
33
+ DEVICES = ("/dev/sda1", "/dev/nvme0n1p2", "/dev/mapper/root", "/dev/loop7", "/dev/sr0")
34
+
35
+ WORDS = (
36
+ "ACCESS GRANTED KERNEL PAYLOAD ROOT DECRYPT SECTOR HANDSHAKE TOKEN BYPASS FIREWALL NODE SIGNAL"
37
+ " CIPHER BREACH UPLINK PROXY DAEMON SHELL EXPLOIT"
38
+ ).split()
39
+
40
+
41
+ class TerminalBoot(Effect):
42
+ TOTAL_LINES = 90
43
+
44
+ def __init__(self, rng: random.Random, unicode: bool = False) -> None:
45
+ super().__init__(rng, unicode)
46
+ self.lines: list[str] = []
47
+ self.cooldown = 0.0
48
+ self.emitted = 0
49
+ self.hold = 0.0
50
+ self.memory = 0
51
+ self.t = 0.0
52
+
53
+ def on_resize(self) -> None:
54
+ self.lines = []
55
+ self.emitted = 0
56
+ self.hold = 0.0
57
+ self.memory = 0
58
+ self.cooldown = 0.0
59
+
60
+ def _next_line(self) -> str:
61
+ rng = self.rng
62
+ n = self.emitted
63
+
64
+ if n == 0:
65
+ return "StaticFX BIOS v2.0 (C) 2026"
66
+ if n == 1:
67
+ return "CPU: Quantum Core @ 4.20GHz Cores: 16"
68
+ if n == 2:
69
+ return "Memory test: 00000K"
70
+ if n < 6:
71
+ return f"Detecting {rng.choice(DEVICES)} ... found"
72
+
73
+ roll = rng.random()
74
+ service = rng.choice(SERVICES)
75
+
76
+ if roll < 0.62:
77
+ return f"[ OK ] Started {service}."
78
+ if roll < 0.78:
79
+ return f"[ INFO ] Mounted {rng.choice(DEVICES)} on /mnt/{rng.randrange(9)}"
80
+ if roll < 0.9:
81
+ return f"[ WARN ] {service} responded slowly ({rng.randint(120, 900)}ms)"
82
+ return f"[ .... ] Starting {service} ..."
83
+
84
+ def advance(self, dt: float) -> None:
85
+ self.t += dt
86
+
87
+ if self.emitted >= self.TOTAL_LINES:
88
+ self.hold += dt
89
+
90
+ if self.hold > 2.5:
91
+ self.on_resize()
92
+
93
+ return
94
+
95
+ self.cooldown -= dt
96
+
97
+ while self.cooldown <= 0.0 and self.emitted < self.TOTAL_LINES:
98
+ self.cooldown += self.rng.uniform(0.01, 0.16)
99
+ self.lines.append(self._next_line())
100
+ self.emitted += 1
101
+
102
+ keep = max(1, self.height - 2)
103
+ self.lines = self.lines[-keep:]
104
+
105
+ if self.memory < 65536 and self.emitted < self.TOTAL_LINES:
106
+ self.memory = min(65536, self.memory + int(dt * 40000))
107
+
108
+ def render(self, frame: Frame, theme: Theme) -> None:
109
+ text = theme.ramp(0.85)
110
+ ok = theme.primary
111
+ warn = theme.accent
112
+ dim = self.color_at(theme, 0.4)
113
+
114
+ for y, line in enumerate(self.lines):
115
+ if line.startswith("Memory test"):
116
+ line = f"Memory test: {self.memory:05d}K"
117
+
118
+ frame.text(0, y, line, text)
119
+
120
+ if line.startswith("[ "):
121
+ tag = line[:8]
122
+ frame.text(0, y, tag, warn if ("WARN" in tag or "...." in tag) else ok)
123
+ elif line.startswith("StaticFX"):
124
+ frame.text(0, y, line, theme.bright)
125
+
126
+ # progress bar
127
+ bar_row = self.height - 1
128
+ width = max(4, min(self.width - 8, 50))
129
+ fraction = clamp(self.emitted / self.TOTAL_LINES)
130
+ filled = int(width * fraction)
131
+ frame.text(0, bar_row, "[" + "#" * filled + "." * (width - filled) + "]", dim)
132
+ frame.text(width + 3, bar_row, f"{int(fraction * 100):3d}%", text)
133
+
134
+ if int(self.t * 2) % 2 == 0:
135
+ cursor_y = min(self.height - 2, len(self.lines))
136
+ frame.put(0, cursor_y, "_", theme.bright)
137
+
138
+
139
+ class DecryptScramble(Effect):
140
+ def __init__(self, rng: random.Random, unicode: bool = False) -> None:
141
+ super().__init__(rng, unicode)
142
+ self.pool = tech_glyphs(False)
143
+ self.t = 0.0
144
+ self.target: list[str] = []
145
+ self.resolve: list[list[float]] = []
146
+ self.scramble: list[list[str]] = []
147
+ self.refresh = 0.0
148
+
149
+ def on_resize(self) -> None:
150
+ rng = self.rng
151
+ self.t = 0.0
152
+ self.target = []
153
+
154
+ for _ in range(self.height):
155
+ parts: list[str] = []
156
+ length = 0
157
+
158
+ while length < self.width:
159
+ word = (
160
+ rng.choice(WORDS)
161
+ if rng.random() < 0.6
162
+ else "0x" + "".join(rng.choices(HEX, k=rng.choice((4, 8))))
163
+ )
164
+ parts.append(word)
165
+ length += len(word) + 1
166
+
167
+ self.target.append(" ".join(parts)[: self.width].ljust(self.width))
168
+
169
+ duration = 5.0
170
+ diagonal = max(1.0, self.height * 0.5 + self.width * 0.05)
171
+ self.resolve = [
172
+ [
173
+ (y * 0.5 + x * 0.05) / diagonal * duration + rng.uniform(0.0, 1.2)
174
+ for x in range(self.width)
175
+ ]
176
+ for y in range(self.height)
177
+ ]
178
+ self.scramble = [rng.choices(self.pool, k=self.width) for _ in range(self.height)]
179
+ self.refresh = 0.0
180
+
181
+ def advance(self, dt: float) -> None:
182
+ self.t += dt
183
+ self.refresh -= dt
184
+
185
+ if self.refresh <= 0.0:
186
+ self.refresh = 1.0 / 14.0
187
+ rng = self.rng
188
+
189
+ for row in self.scramble:
190
+ for x in range(len(row)):
191
+ row[x] = rng.choice(self.pool)
192
+
193
+ if self.t > 5.0 + 1.2 + 2.5:
194
+ self.on_resize()
195
+
196
+ def render(self, frame: Frame, theme: Theme) -> None:
197
+ t = self.t
198
+ scrambled = self.color_at(theme, 0.32)
199
+ fresh = theme.bright
200
+ done = self.color_at(theme, 0.85)
201
+
202
+ for y in range(self.height):
203
+ times = self.resolve[y]
204
+ target = self.target[y]
205
+ noise = self.scramble[y]
206
+
207
+ for x in range(self.width):
208
+ age = t - times[x]
209
+
210
+ if age < 0.0:
211
+ frame.put(x, y, noise[x], scrambled)
212
+ elif target[x] != " ":
213
+ frame.put(x, y, target[x], fresh if age < 0.35 else done)
214
+
215
+
216
+ class BarcodeScan(Effect):
217
+ def __init__(self, rng: random.Random, unicode: bool = False) -> None:
218
+ super().__init__(rng, unicode)
219
+ self.bars: list[bool] = []
220
+ self.digits = ""
221
+ self.t = 0.0
222
+
223
+ def on_resize(self) -> None:
224
+ rng = self.rng
225
+ self.bars = []
226
+ on = True
227
+
228
+ while len(self.bars) < self.width:
229
+ run = rng.choice((1, 1, 2, 2, 3, 4))
230
+ self.bars.extend([on] * run)
231
+ on = not on
232
+
233
+ self.bars = self.bars[: self.width]
234
+ self.digits = "".join(rng.choices("0123456789", k=max(1, self.width // 2)))
235
+
236
+ def advance(self, dt: float) -> None:
237
+ self.t += dt
238
+
239
+ def render(self, frame: Frame, theme: Theme) -> None:
240
+ top = max(0, int(self.height * 0.15))
241
+ bottom = max(top + 1, int(self.height * 0.75))
242
+ block = "█" if self.unicode else "#"
243
+ laser = (0.5 + 0.5 * math.sin(self.t * 0.9)) * (self.width - 1)
244
+
245
+ for x, on in enumerate(self.bars):
246
+ if not on:
247
+ continue
248
+
249
+ distance = abs(x - laser)
250
+ tone = 0.35 + 0.65 * clamp(1.0 - distance / 10.0)
251
+ color = self.color_at(theme, tone)
252
+
253
+ for y in range(top, bottom):
254
+ frame.put(x, y, block, color)
255
+
256
+ lx = int(laser)
257
+ for y in range(max(0, top - 2), min(self.height, bottom + 2)):
258
+ frame.put(lx, y, "|", theme.bright)
259
+
260
+ label_row = min(self.height - 1, bottom + 1)
261
+ start = max(0, (self.width - len(self.digits) * 2) // 2)
262
+
263
+ for index, digit in enumerate(self.digits):
264
+ frame.put(start + index * 2, label_row, digit, self.color_at(theme, 0.6))
265
+
266
+
267
+ # (old direction, new direction) -> the box-drawing corner joining them; y grows downwards.
268
+ CORNERS = {
269
+ ((1, 0), (0, 1)): "┐",
270
+ ((1, 0), (0, -1)): "┘",
271
+ ((-1, 0), (0, 1)): "┌",
272
+ ((-1, 0), (0, -1)): "└",
273
+ ((0, 1), (1, 0)): "└",
274
+ ((0, 1), (-1, 0)): "┘",
275
+ ((0, -1), (1, 0)): "┌",
276
+ ((0, -1), (-1, 0)): "┐",
277
+ }
278
+
279
+
280
+ class Circuit(Effect):
281
+ def __init__(self, rng: random.Random, unicode: bool = False) -> None:
282
+ super().__init__(rng, unicode)
283
+ self.chars: list[list[str]] = []
284
+ self.glow: list[list[float]] = []
285
+ self.walkers: list[list[int]] = []
286
+ self.timer = 0.0
287
+
288
+ def on_resize(self) -> None:
289
+ self.chars = [[" "] * self.width for _ in range(self.height)]
290
+ self.glow = [[0.0] * self.width for _ in range(self.height)]
291
+ self.walkers = [self._spawn() for _ in range(max(3, self.width * self.height // 260))]
292
+ self.timer = 0.0
293
+
294
+ def _spawn(self) -> list[int]:
295
+ rng = self.rng
296
+ dx, dy = rng.choice(((1, 0), (-1, 0), (0, 1), (0, -1)))
297
+ return [rng.randrange(self.width), rng.randrange(self.height), dx, dy, rng.randint(12, 70)]
298
+
299
+ def _glyph(self, direction: tuple[int, int], previous: tuple[int, int]) -> str:
300
+ if direction != previous:
301
+ return CORNERS[previous, direction] if self.unicode else "+"
302
+
303
+ if direction[1] == 0:
304
+ return "─" if self.unicode else "-"
305
+
306
+ return "│" if self.unicode else "|"
307
+
308
+ def advance(self, dt: float) -> None:
309
+ rng = self.rng
310
+ self.timer += dt
311
+
312
+ while self.timer >= 0.035:
313
+ self.timer -= 0.035
314
+
315
+ for index, walker in enumerate(self.walkers):
316
+ x, y, dx, dy, life = walker
317
+ previous = (dx, dy)
318
+
319
+ if rng.random() < 0.17:
320
+ dx, dy = (dy, dx) if rng.random() < 0.5 else (-dy, -dx)
321
+ self.chars[y][x] = self._glyph((dx, dy), previous) # the bend
322
+
323
+ x += dx
324
+ y += dy
325
+ life -= 1
326
+
327
+ if not (0 <= x < self.width and 0 <= y < self.height) or life <= 0:
328
+ if 0 <= x - dx < self.width and 0 <= y - dy < self.height:
329
+ self.chars[y - dy][x - dx] = "o"
330
+ self.glow[y - dy][x - dx] = 1.0
331
+
332
+ self.walkers[index] = self._spawn()
333
+ continue
334
+
335
+ self.chars[y][x] = self._glyph((dx, dy), (dx, dy))
336
+ self.glow[y][x] = 1.0
337
+ walker[0], walker[1], walker[2], walker[3], walker[4] = x, y, dx, dy, life
338
+
339
+ decay = 1.0 - 0.16 * dt
340
+
341
+ for y in range(self.height):
342
+ glow_row = self.glow[y]
343
+ char_row = self.chars[y]
344
+
345
+ for x in range(self.width):
346
+ if glow_row[x] > 0.0:
347
+ glow_row[x] *= decay
348
+
349
+ if glow_row[x] < 0.05:
350
+ glow_row[x] = 0.0
351
+ char_row[x] = " "
352
+
353
+ def render(self, frame: Frame, theme: Theme) -> None:
354
+ for y in range(self.height):
355
+ for x in range(self.width):
356
+ glow = self.glow[y][x]
357
+
358
+ if glow > 0.0:
359
+ frame.put(x, y, self.chars[y][x], self.color_at(theme, 0.15 + 0.85 * glow))
360
+
361
+ for x, y, *_ in self.walkers:
362
+ frame.put(x, y, "@", theme.bright)
@@ -0,0 +1,16 @@
1
+ """Hand-maintained lists over the generated effects.
2
+
3
+ ``EXCLUDED`` is written by ``tools/curate.py``: seeds whose output fails the
4
+ quality gate (blank, frozen, a solid wall, or too slow). ``GALLERY`` is a hand-picked
5
+ selection of the best-looking ones, shown first in the browser.
6
+ """
7
+
8
+ EXCLUDED: frozenset[int] = frozenset({142, 177, 219, 328, 432, 446, 775, 931})
9
+
10
+ # Picked by eye from the top-scoring seeds of `tools/curate.py --rank 100`.
11
+ GALLERY: tuple[int, ...] = (
12
+ 940, 435, 86, 409, 205, 75, 633, 874, 749, 990, 559, 671, 232, 55,
13
+ 804, 725, 883, 445, 744, 369, 979, 643, 531, 13, 875, 764, 610, 403,
14
+ 241, 842, 668, 736, 488, 546, 63, 879, 422, 674, 894, 602, 171, 793,
15
+ 690, 343, 714, 304, 355,
16
+ ) # fmt: skip
@@ -0,0 +1,11 @@
1
+ """The procedural engine: recipes of layered patterns, warps and filters.
2
+
3
+ Every generated effect and every effect made in the Studio is a
4
+ :class:`Recipe` drawn by :class:`EngineEffect`.
5
+ """
6
+
7
+ from staticfx.effects.engine.effect import EngineEffect
8
+ from staticfx.effects.engine.generate import GENERATED_COUNT, describe, generate
9
+ from staticfx.effects.engine.recipe import Recipe
10
+
11
+ __all__ = ["GENERATED_COUNT", "EngineEffect", "Recipe", "describe", "generate"]
@@ -0,0 +1,267 @@
1
+ """Rendering a :class:`Recipe` to the terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+ import time
8
+
9
+ from staticfx.core.color import BLACK, RGB, WHITE, gradient, hsv, tint
10
+ from staticfx.core.frame import Frame
11
+ from staticfx.core.glyphs import TECH
12
+ from staticfx.effects.base import Effect
13
+ from staticfx.effects.engine.filters import BLENDS, FILTERS
14
+ from staticfx.effects.engine.layers import LAYERS
15
+ from staticfx.effects.engine.recipe import ASCII_FALLBACK, Recipe
16
+ from staticfx.effects.engine.warps import WARPS
17
+ from staticfx.themes import Theme
18
+
19
+ # 4x4 ordered-dither thresholds, centred on zero.
20
+ BAYER = [
21
+ [(v + 0.5) / 16.0 - 0.5 for v in row]
22
+ for row in ((0, 8, 2, 10), (12, 4, 14, 6), (3, 11, 1, 9), (15, 7, 13, 5))
23
+ ]
24
+
25
+ RAINBOW = [hsv(i / 256) for i in range(256)]
26
+
27
+ # Block sizes (columns, rows) the field is sampled at. Pure-Python maths per
28
+ # cell is the bottleneck, so big terminals sample every few cells and fill
29
+ # blocks; the adaptive mode steps through these as frame cost changes.
30
+ LEVELS = ((1, 1), (2, 1), (2, 2), (3, 2), (4, 2), (4, 3), (6, 3), (8, 4))
31
+ MAX_SAMPLES = 2400
32
+ SLOW_FRAME, FAST_FRAME = 0.045, 0.012 # seconds of render time
33
+
34
+
35
+ class EngineEffect(Effect):
36
+ """Draws a recipe. The recipe is read every frame, so the Studio can edit
37
+ it in place and see the change immediately."""
38
+
39
+ def __init__(self, rng: random.Random, unicode: bool = False, *, recipe: Recipe) -> None:
40
+ super().__init__(rng, unicode)
41
+ self.recipe = recipe
42
+ self.clock = self._start_time()
43
+ self.level = 0
44
+ self.cost = 0.0
45
+ self.cooldown = 20
46
+ self.step_x = self.step_y = 1
47
+ self.blocks_x = self.blocks_y = 0
48
+ self.xs: list[float] = []
49
+ self.ys: list[float] = []
50
+ self.angles: list[float] = []
51
+ self.values: list[float] = []
52
+ self.previous: list[float] = []
53
+ self._duo: tuple[tuple[RGB, RGB] | None, list[RGB]] = (None, [])
54
+
55
+ def _start_time(self) -> float:
56
+ return (self.recipe.seed * 0.37) % 40.0
57
+
58
+ def on_resize(self) -> None:
59
+ self.level = 0
60
+ while self.level < len(LEVELS) - 1:
61
+ sx, sy = LEVELS[self.level]
62
+ if math.ceil(self.width / sx) * math.ceil(self.height / sy) <= MAX_SAMPLES:
63
+ break
64
+ self.level += 1
65
+ self._layout()
66
+
67
+ def on_reset(self) -> None:
68
+ self.clock = self._start_time()
69
+ self.cooldown = 20
70
+ super().on_reset()
71
+
72
+ def _layout(self) -> None:
73
+ w, h = self.width, self.height
74
+ self.step_x, self.step_y = LEVELS[self.level]
75
+ self.blocks_x = math.ceil(w / self.step_x)
76
+ self.blocks_y = math.ceil(h / self.step_y)
77
+ self.xs, self.ys, self.angles = [], [], []
78
+ for by in range(self.blocks_y):
79
+ ny = (by * self.step_y + self.step_y / 2 - h / 2) / max(1.0, h / 2)
80
+ for bx in range(self.blocks_x):
81
+ nx = (bx * self.step_x + self.step_x / 2 - w / 2) / max(1.0, float(h))
82
+ self.xs.append(nx)
83
+ self.ys.append(ny)
84
+ self.angles.append(math.atan2(ny, nx) / math.tau + 0.5)
85
+ self.values = [0.0] * len(self.xs)
86
+ self.previous = [0.0] * len(self.xs)
87
+
88
+ def advance(self, dt: float) -> None:
89
+ self.clock += dt * self.recipe.speed
90
+ if not self.adaptive:
91
+ return
92
+ self.cooldown -= 1
93
+ if self.cooldown > 0:
94
+ return
95
+ if self.cost > SLOW_FRAME and self.level < len(LEVELS) - 1:
96
+ self.level += 1
97
+ self._layout()
98
+ self.cooldown = 25
99
+ elif self.cost < FAST_FRAME and self.level > 0:
100
+ self.level -= 1
101
+ self._layout()
102
+ self.cooldown = 90
103
+
104
+ def charset(self) -> str:
105
+ chars = self.recipe.charset
106
+ return ASCII_FALLBACK if self.ascii_only and not chars.isascii() else chars
107
+
108
+ def _sample(self) -> None:
109
+ """Fill ``self.values`` with the recipe's brightness at each sample point."""
110
+ recipe, t = self.recipe, self.clock
111
+ warps = [WARPS[s.kind].build(s.params, t) for s in recipe.warps]
112
+ first = LAYERS[recipe.layers[0].kind].build(recipe.layers[0].params, t)
113
+ rest = [(BLENDS[s.blend], LAYERS[s.kind].build(s.params, t)) for s in recipe.layers[1:]]
114
+ filters = [FILTERS[s.kind].build(s.params, t) for s in recipe.filters]
115
+ xs, ys, values = self.xs, self.ys, self.values
116
+
117
+ for i in range(len(xs)):
118
+ x, y = xs[i], ys[i]
119
+ try:
120
+ for warp in warps:
121
+ x, y = warp(x, y)
122
+ v = first(x, y)
123
+ for blend, layer in rest:
124
+ v = blend(v, layer, x, y)
125
+ v = 0.0 if v < 0.0 else 1.0 if v > 1.0 else v
126
+ for f in filters:
127
+ v = f(v)
128
+ v = 0.0 if v < 0.0 else 1.0 if v > 1.0 else v
129
+ except (ValueError, OverflowError, ZeroDivisionError):
130
+ v = 0.0 # e.g. a negative number to a fractional power
131
+ values[i] = v
132
+
133
+ def _post(self) -> None:
134
+ """Auto-gain, trails and pulsing on the sampled values."""
135
+ recipe, values = self.recipe, self.values
136
+ count = len(values)
137
+ peak = max(values) if values else 0.0
138
+
139
+ # Never let a recipe render dim or blank: stretch a dark field to full range,
140
+ # and replace a completely black one with a gentle diagonal gradient.
141
+ if peak < 0.6:
142
+ if peak > 1e-6:
143
+ gain = min(8.0, 0.95 / peak)
144
+ for i in range(count):
145
+ values[i] = min(1.0, values[i] * gain)
146
+ else:
147
+ t, xs, ys = self.clock, self.xs, self.ys
148
+ for i in range(count):
149
+ values[i] = (xs[i] * 3.1 + ys[i] * 2.3 + t * 0.3) % 1.0
150
+
151
+ if recipe.trail:
152
+ previous, decay = self.previous, recipe.trail
153
+ for i in range(count):
154
+ faded = previous[i] * decay
155
+ values[i] = max(values[i], faded)
156
+ previous[i] = values[i]
157
+
158
+ if recipe.pulse:
159
+ level = 0.55 + 0.45 * (0.5 + 0.5 * math.sin(self.clock * recipe.pulse * 6))
160
+ for i in range(count):
161
+ values[i] *= level
162
+
163
+ def _colours(self, theme: Theme) -> list[RGB]:
164
+ """A colour per sample point."""
165
+ palette, t = self.recipe.palette, self.clock
166
+ values = self.values
167
+ if palette.kind == "theme":
168
+ lut = theme.lut
169
+ return [lut[int(v * 255)] for v in values]
170
+ if palette.kind == "duo":
171
+ duo = self._duo_lut(palette.first, palette.second)
172
+ return [duo[int(v * 255)] for v in values]
173
+ out = []
174
+ for i, v in enumerate(values):
175
+ if palette.kind == "rainbow":
176
+ hue = v * palette.spread + t * palette.drift + palette.offset
177
+ k = 0.25 + 0.75 * v
178
+ else: # angular
179
+ hue = self.angles[i] + t * 0.08 + v * 0.3
180
+ k = 0.2 + 0.8 * v
181
+ r, g, b = RAINBOW[int(hue * 255) & 255]
182
+ out.append(RGB(int(r * k), int(g * k), int(b * k)))
183
+ return out
184
+
185
+ def _duo_lut(self, first: RGB, second: RGB) -> list[RGB]:
186
+ if self._duo[0] != (first, second):
187
+ stops = [(0.0, BLACK), (0.45, first), (0.85, second), (1.0, WHITE)]
188
+ self._duo = ((first, second), [gradient(stops, i / 255) for i in range(256)])
189
+ return self._duo[1]
190
+
191
+ def render(self, frame: Frame, theme: Theme) -> None:
192
+ started = time.perf_counter()
193
+ self._sample()
194
+ self._post()
195
+ colours = self._colours(theme)
196
+
197
+ recipe, rng = self.recipe, self.rng
198
+ w, h = self.width, self.height
199
+ sx, sy = self.step_x, self.step_y
200
+ chars = self.charset()
201
+ top = len(chars) - 1
202
+ rnd, pool, pool_size = rng.random, TECH, len(TECH)
203
+ cells, values = frame.cells, self.values
204
+ i = 0
205
+
206
+ for by in range(self.blocks_y):
207
+ y0, y1 = by * sy, min(h, by * sy + sy)
208
+ for bx in range(self.blocks_x):
209
+ v, colour = values[i], colours[i]
210
+ i += 1
211
+ x0, x1 = bx * sx, min(w, bx * sx + sx)
212
+ if recipe.glyph_rain:
213
+ if v > 0.16:
214
+ threshold = 0.35 + v * 0.6
215
+ for y in range(y0, y1):
216
+ for cell in cells[y][x0:x1]:
217
+ if rnd() < threshold:
218
+ cell.char = pool[int(rnd() * pool_size)]
219
+ cell.color = colour
220
+ elif recipe.dither:
221
+ shifted = v * top + 0.5
222
+ for y in range(y0, y1):
223
+ bayer = BAYER[y & 3]
224
+ row = cells[y]
225
+ for x in range(x0, x1):
226
+ index = min(top, int(shifted + bayer[x & 3] * 1.2))
227
+ if index > 0:
228
+ cell = row[x]
229
+ cell.char = chars[index]
230
+ cell.color = colour
231
+ else:
232
+ index = int(v * top + 0.5)
233
+ if index > 0:
234
+ char = chars[index]
235
+ for y in range(y0, y1):
236
+ for cell in cells[y][x0:x1]:
237
+ cell.char = char
238
+ cell.color = colour
239
+
240
+ self._extras(frame, theme)
241
+ self.cost = self.cost * 0.75 + (time.perf_counter() - started) * 0.25
242
+
243
+ def _extras(self, frame: Frame, theme: Theme) -> None:
244
+ recipe, rng = self.recipe, self.rng
245
+ w, h, cells = self.width, self.height, frame.cells
246
+
247
+ if recipe.scanlines:
248
+ for row in cells[::2]:
249
+ for cell in row:
250
+ if cell.char != " ":
251
+ r, g, b = cell.color
252
+ cell.color = RGB(r * 6 // 10, g * 6 // 10, b * 6 // 10)
253
+
254
+ if recipe.glitch and rng.random() < 0.3:
255
+ for _ in range(rng.randint(1, 3)):
256
+ row = cells[rng.randrange(h)]
257
+ shift = rng.randint(1, max(1, w // 6))
258
+ snapshot = [(c.char, c.color) for c in row]
259
+ for x, cell in enumerate(row):
260
+ cell.char, cell.color = snapshot[(x + shift) % w]
261
+
262
+ if recipe.sparkle:
263
+ colour = tint(theme.bright, 0.5)
264
+ for _ in range(int(w * h * recipe.sparkle) + 1):
265
+ cell = cells[rng.randrange(h)][rng.randrange(w)]
266
+ cell.char = rng.choice("*+.'")
267
+ cell.color = colour