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.
- staticfx/__init__.py +3 -0
- staticfx/__main__.py +5 -0
- staticfx/app/__init__.py +1 -0
- staticfx/app/app.py +335 -0
- staticfx/app/keymap.py +163 -0
- staticfx/app/player.py +143 -0
- staticfx/app/session.py +97 -0
- staticfx/cli/__init__.py +20 -0
- staticfx/cli/commands.py +293 -0
- staticfx/cli/parser.py +185 -0
- staticfx/core/__init__.py +1 -0
- staticfx/core/clock.py +30 -0
- staticfx/core/color.py +89 -0
- staticfx/core/frame.py +85 -0
- staticfx/core/glyphs.py +15 -0
- staticfx/core/keys.py +116 -0
- staticfx/core/renderer.py +84 -0
- staticfx/core/terminal.py +148 -0
- staticfx/effects/__init__.py +1 -0
- staticfx/effects/base.py +88 -0
- staticfx/effects/builtin/__init__.py +203 -0
- staticfx/effects/builtin/fields.py +404 -0
- staticfx/effects/builtin/geometry.py +77 -0
- staticfx/effects/builtin/particles.py +623 -0
- staticfx/effects/builtin/shapes.py +255 -0
- staticfx/effects/builtin/signals.py +298 -0
- staticfx/effects/builtin/simulations.py +439 -0
- staticfx/effects/builtin/terminal.py +362 -0
- staticfx/effects/curated.py +16 -0
- staticfx/effects/engine/__init__.py +11 -0
- staticfx/effects/engine/effect.py +267 -0
- staticfx/effects/engine/filters.py +115 -0
- staticfx/effects/engine/generate.py +203 -0
- staticfx/effects/engine/layers.py +396 -0
- staticfx/effects/engine/recipe.py +215 -0
- staticfx/effects/engine/schema.py +87 -0
- staticfx/effects/engine/warps.py +203 -0
- staticfx/effects/registry.py +103 -0
- staticfx/effects/spec.py +51 -0
- staticfx/integrations.py +48 -0
- staticfx/library/__init__.py +1 -0
- staticfx/library/config.py +122 -0
- staticfx/library/effects.py +159 -0
- staticfx/library/files.py +44 -0
- staticfx/library/legacy.py +180 -0
- staticfx/library/looks.py +134 -0
- staticfx/postfx.py +78 -0
- staticfx/py.typed +0 -0
- staticfx/themes.py +162 -0
- staticfx/ui/__init__.py +1 -0
- staticfx/ui/browser.py +269 -0
- staticfx/ui/dialogs.py +95 -0
- staticfx/ui/help.py +99 -0
- staticfx/ui/pickers.py +221 -0
- staticfx/ui/play.py +213 -0
- staticfx/ui/screen.py +39 -0
- staticfx/ui/settings.py +262 -0
- staticfx/ui/studio.py +433 -0
- staticfx/ui/widgets.py +196 -0
- staticfx-2.0.0.dist-info/METADATA +164 -0
- staticfx-2.0.0.dist-info/RECORD +65 -0
- staticfx-2.0.0.dist-info/WHEEL +5 -0
- staticfx-2.0.0.dist-info/entry_points.txt +2 -0
- staticfx-2.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|