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,396 @@
|
|
|
1
|
+
"""Pattern generators: functions from a point to a brightness in 0..1.
|
|
2
|
+
|
|
3
|
+
Coordinates arrive centred on the screen, with y spanning about -1..1 and x
|
|
4
|
+
scaled to keep circles round on a terminal grid.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
from staticfx.effects.engine.schema import Kind, Param, Params, registry
|
|
13
|
+
|
|
14
|
+
Field = Callable[[float, float], float]
|
|
15
|
+
|
|
16
|
+
sin, cos, hypot, atan2, tau = math.sin, math.cos, math.hypot, math.atan2, math.tau
|
|
17
|
+
|
|
18
|
+
SCALE = Param("scale", "Scale", 6.0, 1.0, 20.0, 0.5)
|
|
19
|
+
SCALE_Y = Param("scale_y", "Scale (vertical)", 6.0, 1.0, 20.0, 0.5)
|
|
20
|
+
SPEED = Param("speed", "Motion", 1.0, -3.0, 3.0, 0.1)
|
|
21
|
+
ARMS = Param("arms", "Arms", 4, 1, 12, 1, integer=True)
|
|
22
|
+
COUNT = Param("count", "Count", 4, 2, 9, 1, integer=True)
|
|
23
|
+
|
|
24
|
+
DISTANCES: dict[str, Callable[[float, float], float]] = {
|
|
25
|
+
"circle": hypot,
|
|
26
|
+
"diamond": lambda x, y: abs(x) + abs(y),
|
|
27
|
+
"square": lambda x, y: max(abs(x), abs(y)),
|
|
28
|
+
"star": lambda x, y: (abs(x) ** 0.5 + abs(y) ** 0.5) ** 2,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Interesting places to zoom into on the Mandelbrot set.
|
|
32
|
+
MANDELBROT_SPOTS: dict[str, tuple[float, float]] = {
|
|
33
|
+
"seahorse": (-0.745, 0.113),
|
|
34
|
+
"spiral": (-0.16, 1.04),
|
|
35
|
+
"needle": (-1.25, 0.02),
|
|
36
|
+
"elephant": (0.28, 0.008),
|
|
37
|
+
"bulb": (-0.7, 0.3),
|
|
38
|
+
"tendril": (-0.1, 0.65),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
SHAPES: dict[str, Callable[[float, float, int], float]] = {
|
|
42
|
+
"box": lambda x, y, n: max(abs(x), abs(y)),
|
|
43
|
+
"cross": lambda x, y, n: min(abs(x), abs(y)),
|
|
44
|
+
"star": lambda x, y, n: hypot(x, y) - 0.3 * cos(n * atan2(y, x)),
|
|
45
|
+
"triangle": lambda x, y, n: max(abs(x) * 0.866 + y * 0.5, -y),
|
|
46
|
+
"ring": lambda x, y, n: abs(hypot(x, y) - 0.5),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _hash(ix: int, iy: int, seed: int) -> float:
|
|
51
|
+
n = (ix * 374761393 + iy * 668265263 + seed * 1442695041) & 0xFFFFFFFF
|
|
52
|
+
n = ((n ^ (n >> 13)) * 1274126177) & 0xFFFFFFFF
|
|
53
|
+
return (n ^ (n >> 16)) / 4294967295.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def value_noise(x: float, y: float, seed: int) -> float:
|
|
57
|
+
"""Smooth 2D value noise in 0..1."""
|
|
58
|
+
ix, iy = math.floor(x), math.floor(y)
|
|
59
|
+
fx, fy = x - ix, y - iy
|
|
60
|
+
ux, uy = fx * fx * (3 - 2 * fx), fy * fy * (3 - 2 * fy)
|
|
61
|
+
a, b = _hash(ix, iy, seed), _hash(ix + 1, iy, seed)
|
|
62
|
+
c, d = _hash(ix, iy + 1, seed), _hash(ix + 1, iy + 1, seed)
|
|
63
|
+
top = a + (b - a) * ux
|
|
64
|
+
return top + (c + (d - c) * ux - top) * uy
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def grid(p: Params, t: float) -> Field:
|
|
68
|
+
fx, fy, a = p["scale"], p["scale_y"], t * p["speed"]
|
|
69
|
+
return lambda x, y: 0.5 + 0.25 * (sin(x * fx + a) + sin(y * fy - a))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def rings(p: Params, t: float) -> Field:
|
|
73
|
+
dist, f, a = DISTANCES[p["shape"]], p["scale"], t * p["speed"] * 2
|
|
74
|
+
return lambda x, y: 0.5 + 0.5 * sin(dist(x, y) * f - a)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def spiral(p: Params, t: float) -> Field:
|
|
78
|
+
n, f, a = p["arms"], p["scale"] * 0.6, t * p["speed"] * 2
|
|
79
|
+
return lambda x, y: 0.5 + 0.5 * sin(hypot(x, y) * f + n * atan2(y, x) - a)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def rays(p: Params, t: float) -> Field:
|
|
83
|
+
n, twist, a = p["arms"], p["scale"] * 0.2, t * p["speed"]
|
|
84
|
+
return lambda x, y: 0.5 + 0.5 * sin(n * atan2(y, x) + a + hypot(x, y) * twist)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def checker(p: Params, t: float) -> Field:
|
|
88
|
+
fx, fy, a = p["scale"], p["scale_y"], t * p["speed"]
|
|
89
|
+
b = a * 0.7
|
|
90
|
+
return lambda x, y: 0.5 + 0.5 * max(-1.0, min(1.0, 3 * sin(x * fx + a) * sin(y * fy + b)))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def moire(p: Params, t: float) -> Field:
|
|
94
|
+
f, a = p["scale"], t * p["speed"] * 0.5
|
|
95
|
+
ax, ay, bx, by = 0.3 * cos(a), 0.3 * sin(a), -0.3 * cos(a * 0.7), 0.3 * sin(a * 1.3)
|
|
96
|
+
return lambda x, y: (
|
|
97
|
+
0.5 + 0.25 * (sin(hypot(x - ax, y - ay) * f) + sin(hypot(x - bx, y - by) * f * 1.04))
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def flow(p: Params, t: float) -> Field:
|
|
102
|
+
f, a = p["scale"] * 0.25 + 0.5, t * p["speed"]
|
|
103
|
+
|
|
104
|
+
def field(x: float, y: float) -> float:
|
|
105
|
+
x, y = x * f, y * f
|
|
106
|
+
return (
|
|
107
|
+
sin(x * 1.7 + sin(y * 2.1 + a))
|
|
108
|
+
+ sin(y * 1.3 + sin(x * 2.7 - a * 1.3))
|
|
109
|
+
+ sin((x + y) * 1.1 + a * 0.7)
|
|
110
|
+
) / 6.0 + 0.5
|
|
111
|
+
|
|
112
|
+
return field
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def blobs(p: Params, t: float) -> Field:
|
|
116
|
+
s = t * p["speed"]
|
|
117
|
+
centres = [
|
|
118
|
+
(
|
|
119
|
+
0.6 * sin(s * (0.4 + i * 0.23) + i * 2.1),
|
|
120
|
+
0.6 * cos(s * (0.3 + i * 0.19) + i * 1.3),
|
|
121
|
+
(0.14 + 0.05 * (i % 3)) ** 2,
|
|
122
|
+
)
|
|
123
|
+
for i in range(p["count"])
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
def field(x: float, y: float) -> float:
|
|
127
|
+
total = 0.0
|
|
128
|
+
for cx, cy, r2 in centres:
|
|
129
|
+
dx, dy = x - cx, y - cy
|
|
130
|
+
total += r2 / (dx * dx + dy * dy + 0.01)
|
|
131
|
+
return min(1.0, total * 0.35)
|
|
132
|
+
|
|
133
|
+
return field
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def julia(p: Params, t: float) -> Field:
|
|
137
|
+
a = t * p["speed"] * 0.3
|
|
138
|
+
c = complex(p["c_real"] + 0.08 * sin(a), p["c_imag"] + 0.08 * cos(a))
|
|
139
|
+
|
|
140
|
+
def field(x: float, y: float) -> float:
|
|
141
|
+
z = complex(x * 1.6, y * 1.6)
|
|
142
|
+
n = 0
|
|
143
|
+
while n < 7 and z.real * z.real + z.imag * z.imag < 4.0:
|
|
144
|
+
z = z * z + c
|
|
145
|
+
n += 1
|
|
146
|
+
return n / 7.0
|
|
147
|
+
|
|
148
|
+
return field
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def mandelbrot(p: Params, t: float) -> Field:
|
|
152
|
+
px, py = MANDELBROT_SPOTS[p["spot"]]
|
|
153
|
+
zoom = 0.9 * math.exp(-2.2 * (0.5 + 0.5 * sin(t * abs(p["speed"]) * 0.35)))
|
|
154
|
+
|
|
155
|
+
def field(x: float, y: float) -> float:
|
|
156
|
+
c = complex(px + x * zoom, py + y * zoom)
|
|
157
|
+
z = 0j
|
|
158
|
+
n = 0
|
|
159
|
+
while n < 10 and z.real * z.real + z.imag * z.imag < 4.0:
|
|
160
|
+
z = z * z + c
|
|
161
|
+
n += 1
|
|
162
|
+
return n / 10.0
|
|
163
|
+
|
|
164
|
+
return field
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def voronoi(p: Params, t: float) -> Field:
|
|
168
|
+
s, f, style = t * p["speed"], p["scale"], p["style"]
|
|
169
|
+
points = [
|
|
170
|
+
(
|
|
171
|
+
0.8 * sin(s * 0.5 * (0.5 + i * 0.17) + i * 1.9),
|
|
172
|
+
0.8 * cos(s * 0.4 * (0.6 + i * 0.13) + i * 2.7),
|
|
173
|
+
)
|
|
174
|
+
for i in range(p["count"] + 2)
|
|
175
|
+
]
|
|
176
|
+
count = len(points)
|
|
177
|
+
|
|
178
|
+
def field(x: float, y: float) -> float:
|
|
179
|
+
nearest = second = 9.0
|
|
180
|
+
owner = 0
|
|
181
|
+
for i, (px, py) in enumerate(points):
|
|
182
|
+
d = hypot(x - px, y - py)
|
|
183
|
+
if d < nearest:
|
|
184
|
+
second, nearest, owner = nearest, d, i
|
|
185
|
+
elif d < second:
|
|
186
|
+
second = d
|
|
187
|
+
if style == "edges":
|
|
188
|
+
return 1.0 - min(1.0, (second - nearest) * f * 0.6)
|
|
189
|
+
if style == "distance":
|
|
190
|
+
return min(1.0, nearest * f * 0.35)
|
|
191
|
+
return owner / count * 0.75 + min(0.25, nearest * 0.3)
|
|
192
|
+
|
|
193
|
+
return field
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def hexagons(p: Params, t: float) -> Field:
|
|
197
|
+
f, a = p["scale"] * 0.6 + 2.0, t * p["speed"]
|
|
198
|
+
return lambda x, y: (
|
|
199
|
+
0.5
|
|
200
|
+
+ (cos(f * x + a) + cos(f * (0.5 * x + 0.866 * y) - a) + cos(f * (0.5 * x - 0.866 * y) + a))
|
|
201
|
+
/ 6.0
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def lissajous(p: Params, t: float) -> Field:
|
|
206
|
+
fx, fy, f, a = p["x_waves"], p["y_waves"], p["scale"] * 0.3, t * p["speed"]
|
|
207
|
+
return lambda x, y: 0.5 + 0.5 * sin(fx * x * f + a) * cos(fy * y * f - a)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def tunnel(p: Params, t: float) -> Field:
|
|
211
|
+
n, f, a = p["arms"], p["scale"] * 0.5, t * p["speed"] * 1.5
|
|
212
|
+
return lambda x, y: 0.5 + 0.5 * sin((0.3 / (hypot(x, y) + 0.02) - a) * f + n * atan2(y, x))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def sources(p: Params, t: float) -> Field:
|
|
216
|
+
count, f, s = max(2, p["count"] - 1), p["scale"], t * p["speed"]
|
|
217
|
+
a = s * 2
|
|
218
|
+
points = [
|
|
219
|
+
(0.7 * cos(s * 0.3 + i * tau / count), 0.7 * sin(s * 0.3 + i * tau / count))
|
|
220
|
+
for i in range(count)
|
|
221
|
+
]
|
|
222
|
+
return lambda x, y: (
|
|
223
|
+
0.5 + 0.5 * sum(sin(hypot(x - px, y - py) * f - a) for px, py in points) / count
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def shape(p: Params, t: float) -> Field:
|
|
228
|
+
dist, n, f, a = SHAPES[p["shape"]], p["points"], p["scale"] * 2, t * p["speed"] * 2
|
|
229
|
+
return lambda x, y: 0.5 + 0.5 * sin(dist(x, y, n) * f - a)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def noise(p: Params, t: float) -> Field:
|
|
233
|
+
f, a, seed = p["scale"] * 0.35 + 1.5, t * p["speed"] * 0.5, p["seed"]
|
|
234
|
+
return lambda x, y: (
|
|
235
|
+
value_noise(x * f + a, y * f - a * 0.7, seed) * 0.65
|
|
236
|
+
+ value_noise(x * f * 2.1 - a, y * f * 2.1 + a, seed + 7) * 0.35
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
LAYERS = registry(
|
|
241
|
+
Kind(
|
|
242
|
+
"grid",
|
|
243
|
+
"Grid",
|
|
244
|
+
grid,
|
|
245
|
+
(SCALE, SCALE_Y, SPEED),
|
|
246
|
+
summary="crossing sine waves",
|
|
247
|
+
nouns=("Lattice", "Mesh", "Weave"),
|
|
248
|
+
),
|
|
249
|
+
Kind(
|
|
250
|
+
"rings",
|
|
251
|
+
"Rings",
|
|
252
|
+
rings,
|
|
253
|
+
(SCALE, SPEED, Param("shape", "Ring shape", "circle", choices=tuple(DISTANCES))),
|
|
254
|
+
summary="concentric rings",
|
|
255
|
+
nouns=("Ripples", "Halo", "Pulsar"),
|
|
256
|
+
),
|
|
257
|
+
Kind(
|
|
258
|
+
"spiral",
|
|
259
|
+
"Spiral",
|
|
260
|
+
spiral,
|
|
261
|
+
(SCALE, SPEED, ARMS),
|
|
262
|
+
summary="rotating spiral arms",
|
|
263
|
+
nouns=("Vortex", "Nautilus", "Helix"),
|
|
264
|
+
),
|
|
265
|
+
Kind(
|
|
266
|
+
"rays",
|
|
267
|
+
"Rays",
|
|
268
|
+
rays,
|
|
269
|
+
(Param("scale", "Twist", 6.0, 0.0, 20.0, 0.5), SPEED, ARMS),
|
|
270
|
+
summary="light rays from the centre",
|
|
271
|
+
nouns=("Sunburst", "Starburst", "Beacon"),
|
|
272
|
+
),
|
|
273
|
+
Kind(
|
|
274
|
+
"checker",
|
|
275
|
+
"Checker",
|
|
276
|
+
checker,
|
|
277
|
+
(SCALE, SCALE_Y, SPEED),
|
|
278
|
+
summary="sliding checkerboard",
|
|
279
|
+
nouns=("Mosaic", "Chessboard", "Tiles"),
|
|
280
|
+
),
|
|
281
|
+
Kind(
|
|
282
|
+
"moire",
|
|
283
|
+
"Moire",
|
|
284
|
+
moire,
|
|
285
|
+
(SCALE, SPEED),
|
|
286
|
+
summary="two interfering ring sets",
|
|
287
|
+
nouns=("Moire", "Interference", "Shimmer"),
|
|
288
|
+
),
|
|
289
|
+
Kind(
|
|
290
|
+
"flow",
|
|
291
|
+
"Flow",
|
|
292
|
+
flow,
|
|
293
|
+
(SCALE, SPEED),
|
|
294
|
+
summary="liquid domain-warped waves",
|
|
295
|
+
nouns=("Aether", "Current", "Nebula"),
|
|
296
|
+
),
|
|
297
|
+
Kind(
|
|
298
|
+
"blobs",
|
|
299
|
+
"Blobs",
|
|
300
|
+
blobs,
|
|
301
|
+
(SPEED, COUNT),
|
|
302
|
+
summary="merging metaballs",
|
|
303
|
+
nouns=("Lava", "Orbs", "Amoeba"),
|
|
304
|
+
),
|
|
305
|
+
Kind(
|
|
306
|
+
"julia",
|
|
307
|
+
"Julia set",
|
|
308
|
+
julia,
|
|
309
|
+
(
|
|
310
|
+
SPEED,
|
|
311
|
+
Param("c_real", "Shape (real)", -0.6, -1.0, 0.5, 0.02),
|
|
312
|
+
Param("c_imag", "Shape (imaginary)", 0.2, -1.0, 1.0, 0.02),
|
|
313
|
+
),
|
|
314
|
+
summary="the Julia fractal, breathing",
|
|
315
|
+
nouns=("Julia", "Mandala", "Filigree"),
|
|
316
|
+
),
|
|
317
|
+
Kind(
|
|
318
|
+
"mandelbrot",
|
|
319
|
+
"Mandelbrot",
|
|
320
|
+
mandelbrot,
|
|
321
|
+
(SPEED, Param("spot", "Zoom target", "seahorse", choices=tuple(MANDELBROT_SPOTS))),
|
|
322
|
+
summary="zooming into the Mandelbrot set",
|
|
323
|
+
nouns=("Abyss", "Zoom", "Cardioid"),
|
|
324
|
+
),
|
|
325
|
+
Kind(
|
|
326
|
+
"voronoi",
|
|
327
|
+
"Voronoi",
|
|
328
|
+
voronoi,
|
|
329
|
+
(
|
|
330
|
+
SCALE,
|
|
331
|
+
SPEED,
|
|
332
|
+
COUNT,
|
|
333
|
+
Param("style", "Style", "edges", choices=("edges", "distance", "cells")),
|
|
334
|
+
),
|
|
335
|
+
summary="drifting cells",
|
|
336
|
+
nouns=("Shards", "Cells", "Fracture"),
|
|
337
|
+
),
|
|
338
|
+
Kind(
|
|
339
|
+
"hexagons",
|
|
340
|
+
"Hexagons",
|
|
341
|
+
hexagons,
|
|
342
|
+
(SCALE, SPEED),
|
|
343
|
+
summary="honeycomb interference",
|
|
344
|
+
nouns=("Honeycomb", "Hive", "Lace"),
|
|
345
|
+
),
|
|
346
|
+
Kind(
|
|
347
|
+
"lissajous",
|
|
348
|
+
"Lissajous",
|
|
349
|
+
lissajous,
|
|
350
|
+
(
|
|
351
|
+
SCALE,
|
|
352
|
+
SPEED,
|
|
353
|
+
Param("x_waves", "Waves across", 3, 1, 9, 1, integer=True),
|
|
354
|
+
Param("y_waves", "Waves down", 4, 1, 12, 1, integer=True),
|
|
355
|
+
),
|
|
356
|
+
summary="woven sine curves",
|
|
357
|
+
nouns=("Loom", "Harmonic", "Oscillator"),
|
|
358
|
+
),
|
|
359
|
+
Kind(
|
|
360
|
+
"tunnel",
|
|
361
|
+
"Tunnel",
|
|
362
|
+
tunnel,
|
|
363
|
+
(SCALE, SPEED, ARMS),
|
|
364
|
+
summary="flying down a twisted tunnel",
|
|
365
|
+
nouns=("Tunnel", "Portal", "Warp"),
|
|
366
|
+
),
|
|
367
|
+
Kind(
|
|
368
|
+
"sources",
|
|
369
|
+
"Wave sources",
|
|
370
|
+
sources,
|
|
371
|
+
(SCALE, SPEED, COUNT),
|
|
372
|
+
summary="circling wave emitters",
|
|
373
|
+
nouns=("Sonar", "Chorus", "Wavefront"),
|
|
374
|
+
),
|
|
375
|
+
Kind(
|
|
376
|
+
"shape",
|
|
377
|
+
"Shape",
|
|
378
|
+
shape,
|
|
379
|
+
(
|
|
380
|
+
SCALE,
|
|
381
|
+
SPEED,
|
|
382
|
+
Param("shape", "Shape", "ring", choices=tuple(SHAPES)),
|
|
383
|
+
Param("points", "Star points", 5, 2, 12, 1, integer=True),
|
|
384
|
+
),
|
|
385
|
+
summary="outlines pulsing outwards",
|
|
386
|
+
nouns=("Glyph", "Emblem", "Sigil"),
|
|
387
|
+
),
|
|
388
|
+
Kind(
|
|
389
|
+
"noise",
|
|
390
|
+
"Noise",
|
|
391
|
+
noise,
|
|
392
|
+
(SCALE, SPEED, Param("seed", "Noise seed", 1, 0, 1 << 20, 1, integer=True)),
|
|
393
|
+
summary="drifting cloudy noise",
|
|
394
|
+
nouns=("Terrain", "Cloud", "Storm"),
|
|
395
|
+
),
|
|
396
|
+
)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The recipe: a complete, serialisable description of a procedural effect.
|
|
2
|
+
|
|
3
|
+
Rendering a recipe goes through these stages for every cell::
|
|
4
|
+
|
|
5
|
+
warps -> layer 1 (blend) layer 2 (blend) layer 3 -> filters
|
|
6
|
+
-> extras (trail, pulse) -> characters -> palette
|
|
7
|
+
|
|
8
|
+
Recipes are plain data. ``to_dict``/``from_dict`` round-trip them through
|
|
9
|
+
JSON, which is the ``recipe`` object inside a ``.sfx`` file (the format is
|
|
10
|
+
documented in ``docs/EFFECT_FORMAT.md``).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from staticfx.core.color import RGB, parse_hex, to_hex
|
|
20
|
+
from staticfx.effects.engine.filters import BLENDS, FILTERS
|
|
21
|
+
from staticfx.effects.engine.layers import LAYERS
|
|
22
|
+
from staticfx.effects.engine.schema import Kind, Params
|
|
23
|
+
from staticfx.effects.engine.warps import WARPS
|
|
24
|
+
|
|
25
|
+
MAX_LAYERS, MAX_WARPS, MAX_FILTERS = 3, 3, 3
|
|
26
|
+
|
|
27
|
+
# Character sets ordered dark -> bright. The first character must be a space.
|
|
28
|
+
CHARSETS = (
|
|
29
|
+
" .:-=+*#%@",
|
|
30
|
+
" .oO0@",
|
|
31
|
+
" .,:;ox%#@",
|
|
32
|
+
" .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
|
|
33
|
+
" .:#@",
|
|
34
|
+
" #",
|
|
35
|
+
" .+x*#X@",
|
|
36
|
+
" /\\|-+X#",
|
|
37
|
+
" .:!|",
|
|
38
|
+
" 0123456789",
|
|
39
|
+
" ._-~=+*",
|
|
40
|
+
" ░▒▓█",
|
|
41
|
+
" ▁▂▃▄▅▆▇█",
|
|
42
|
+
)
|
|
43
|
+
ASCII_FALLBACK = CHARSETS[0]
|
|
44
|
+
|
|
45
|
+
PALETTES = ("theme", "rainbow", "duo", "angular")
|
|
46
|
+
MAGENTA, CYAN = RGB(255, 0, 170), RGB(0, 220, 255)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Stage:
|
|
51
|
+
"""One layer, warp or filter. ``blend`` joins a layer to the ones before it."""
|
|
52
|
+
|
|
53
|
+
kind: str
|
|
54
|
+
params: Params
|
|
55
|
+
blend: str = "average"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Palette:
|
|
60
|
+
"""How brightness becomes colour.
|
|
61
|
+
|
|
62
|
+
* ``theme`` - follow the current theme
|
|
63
|
+
* ``rainbow`` - hue from brightness (``spread``), drifting over time (``drift``)
|
|
64
|
+
* ``duo`` - black -> ``first`` -> ``second`` -> white
|
|
65
|
+
* ``angular`` - hue from the angle around the centre
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
kind: str = "theme"
|
|
69
|
+
spread: float = 1.0
|
|
70
|
+
drift: float = 0.0
|
|
71
|
+
offset: float = 0.0
|
|
72
|
+
first: RGB = MAGENTA
|
|
73
|
+
second: RGB = CYAN
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Recipe:
|
|
78
|
+
layers: list[Stage]
|
|
79
|
+
warps: list[Stage] = field(default_factory=list)
|
|
80
|
+
filters: list[Stage] = field(default_factory=list)
|
|
81
|
+
speed: float = 1.0
|
|
82
|
+
charset: str = CHARSETS[0]
|
|
83
|
+
glyph_rain: bool = False # draw random tech glyphs instead of the charset
|
|
84
|
+
dither: bool = False
|
|
85
|
+
palette: Palette = field(default_factory=Palette)
|
|
86
|
+
trail: float = 0.0 # 0 = off; otherwise how slowly bright cells fade (0.5-0.98)
|
|
87
|
+
pulse: float = 0.0 # 0 = off; otherwise breaths per second
|
|
88
|
+
scanlines: bool = False
|
|
89
|
+
glitch: bool = False
|
|
90
|
+
sparkle: float = 0.0 # fraction of cells that sparkle each frame
|
|
91
|
+
seed: int = 0 # offsets the animation clock so siblings don't move in lockstep
|
|
92
|
+
|
|
93
|
+
def copy(self) -> Recipe:
|
|
94
|
+
return copy.deepcopy(self)
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, Any]:
|
|
97
|
+
def stages(items: list[Stage], with_blend: bool = False) -> list[dict[str, Any]]:
|
|
98
|
+
out = []
|
|
99
|
+
for i, stage in enumerate(items):
|
|
100
|
+
entry: dict[str, Any] = {"kind": stage.kind, **stage.params}
|
|
101
|
+
if with_blend and i:
|
|
102
|
+
entry["blend"] = stage.blend
|
|
103
|
+
out.append(entry)
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
pal = self.palette
|
|
107
|
+
palette: dict[str, Any] = {"kind": pal.kind}
|
|
108
|
+
if pal.kind == "rainbow":
|
|
109
|
+
palette.update(spread=pal.spread, drift=pal.drift, offset=pal.offset)
|
|
110
|
+
elif pal.kind == "duo":
|
|
111
|
+
palette.update(first=to_hex(pal.first), second=to_hex(pal.second))
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"layers": stages(self.layers, with_blend=True),
|
|
115
|
+
"warps": stages(self.warps),
|
|
116
|
+
"filters": stages(self.filters),
|
|
117
|
+
"speed": self.speed,
|
|
118
|
+
"charset": self.charset,
|
|
119
|
+
"glyph_rain": self.glyph_rain,
|
|
120
|
+
"dither": self.dither,
|
|
121
|
+
"palette": palette,
|
|
122
|
+
"trail": self.trail,
|
|
123
|
+
"pulse": self.pulse,
|
|
124
|
+
"scanlines": self.scanlines,
|
|
125
|
+
"glitch": self.glitch,
|
|
126
|
+
"sparkle": self.sparkle,
|
|
127
|
+
"seed": self.seed,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def from_dict(cls, data: Any) -> Recipe:
|
|
132
|
+
"""Build a recipe from untrusted JSON.
|
|
133
|
+
|
|
134
|
+
Out-of-range numbers are clamped and missing values take defaults;
|
|
135
|
+
anything structurally wrong raises ``ValueError`` listing every problem.
|
|
136
|
+
"""
|
|
137
|
+
if not isinstance(data, dict):
|
|
138
|
+
raise ValueError("recipe must be an object")
|
|
139
|
+
problems: list[str] = []
|
|
140
|
+
|
|
141
|
+
def stages(key: str, kinds: dict[str, Kind], limit: int) -> list[Stage]:
|
|
142
|
+
entries = data.get(key, [])
|
|
143
|
+
if not isinstance(entries, list) or len(entries) > limit:
|
|
144
|
+
problems.append(f"'{key}' must be a list of at most {limit}")
|
|
145
|
+
return []
|
|
146
|
+
out = []
|
|
147
|
+
for entry in entries:
|
|
148
|
+
kind = entry.get("kind") if isinstance(entry, dict) else entry
|
|
149
|
+
if not isinstance(entry, dict) or not isinstance(kind, str) or kind not in kinds:
|
|
150
|
+
problems.append(f"unknown {key[:-1]} kind {kind!r}")
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
params = kinds[kind].clean(entry)
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
problems.append(f"{kind}: {exc}")
|
|
156
|
+
continue
|
|
157
|
+
blend = entry.get("blend", "average")
|
|
158
|
+
if not isinstance(blend, str) or blend not in BLENDS:
|
|
159
|
+
problems.append(f"unknown blend {blend!r}")
|
|
160
|
+
continue
|
|
161
|
+
out.append(Stage(kind, params, blend))
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
layers = stages("layers", LAYERS, MAX_LAYERS)
|
|
165
|
+
warps = stages("warps", WARPS, MAX_WARPS)
|
|
166
|
+
filters = stages("filters", FILTERS, MAX_FILTERS)
|
|
167
|
+
if not layers and not problems:
|
|
168
|
+
problems.append("a recipe needs at least one layer")
|
|
169
|
+
|
|
170
|
+
def number(key: str, default: float, low: float, high: float) -> float:
|
|
171
|
+
value = data.get(key, default)
|
|
172
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
173
|
+
problems.append(f"'{key}' must be a number")
|
|
174
|
+
return default
|
|
175
|
+
return min(high, max(low, float(value)))
|
|
176
|
+
|
|
177
|
+
charset = data.get("charset", CHARSETS[0])
|
|
178
|
+
if not isinstance(charset, str) or len(charset) < 2 or charset[0] != " ":
|
|
179
|
+
problems.append("'charset' must be a string of 2+ characters starting with a space")
|
|
180
|
+
charset = CHARSETS[0]
|
|
181
|
+
|
|
182
|
+
palette = Palette()
|
|
183
|
+
raw = data.get("palette", {"kind": "theme"})
|
|
184
|
+
if not isinstance(raw, dict) or raw.get("kind") not in PALETTES:
|
|
185
|
+
problems.append(f"palette kind must be one of {', '.join(PALETTES)}")
|
|
186
|
+
else:
|
|
187
|
+
palette.kind = raw["kind"]
|
|
188
|
+
try:
|
|
189
|
+
palette.spread = float(raw.get("spread", palette.spread))
|
|
190
|
+
palette.drift = float(raw.get("drift", palette.drift))
|
|
191
|
+
palette.offset = float(raw.get("offset", palette.offset)) % 1.0
|
|
192
|
+
palette.first = parse_hex(str(raw.get("first", to_hex(palette.first))))
|
|
193
|
+
palette.second = parse_hex(str(raw.get("second", to_hex(palette.second))))
|
|
194
|
+
except (TypeError, ValueError) as exc:
|
|
195
|
+
problems.append(f"palette: {exc}")
|
|
196
|
+
|
|
197
|
+
recipe = cls(
|
|
198
|
+
layers=layers,
|
|
199
|
+
warps=warps,
|
|
200
|
+
filters=filters,
|
|
201
|
+
speed=number("speed", 1.0, 0.05, 6.0),
|
|
202
|
+
charset=charset,
|
|
203
|
+
glyph_rain=bool(data.get("glyph_rain", False)),
|
|
204
|
+
dither=bool(data.get("dither", False)),
|
|
205
|
+
palette=palette,
|
|
206
|
+
trail=number("trail", 0.0, 0.0, 0.98),
|
|
207
|
+
pulse=number("pulse", 0.0, 0.0, 8.0),
|
|
208
|
+
scanlines=bool(data.get("scanlines", False)),
|
|
209
|
+
glitch=bool(data.get("glitch", False)),
|
|
210
|
+
sparkle=number("sparkle", 0.0, 0.0, 0.05),
|
|
211
|
+
seed=int(number("seed", 0, 0, 2**31)),
|
|
212
|
+
)
|
|
213
|
+
if problems:
|
|
214
|
+
raise ValueError("; ".join(problems))
|
|
215
|
+
return recipe
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Describing the building blocks of a recipe.
|
|
2
|
+
|
|
3
|
+
Every layer, warp and filter kind declares its parameters once, here, as
|
|
4
|
+
:class:`Param` objects. That single description drives validation of saved
|
|
5
|
+
files, default values, the Effect Studio's controls and the docs, so the
|
|
6
|
+
four can never disagree.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
Params = dict[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Param:
|
|
20
|
+
"""One tunable value.
|
|
21
|
+
|
|
22
|
+
Numeric params have ``low``/``high`` bounds and a ``step`` for the Studio;
|
|
23
|
+
``integer`` params are rounded. Choice params list their ``choices``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
label: str
|
|
28
|
+
default: Any
|
|
29
|
+
low: float = 0.0
|
|
30
|
+
high: float = 1.0
|
|
31
|
+
step: float = 0.1
|
|
32
|
+
integer: bool = False
|
|
33
|
+
choices: tuple[str, ...] = ()
|
|
34
|
+
|
|
35
|
+
def clean(self, value: Any) -> Any:
|
|
36
|
+
"""Coerce ``value`` into range; raises ``ValueError`` if it can't be."""
|
|
37
|
+
if self.choices:
|
|
38
|
+
if value not in self.choices:
|
|
39
|
+
raise ValueError(f"{self.name} must be one of {', '.join(self.choices)}")
|
|
40
|
+
return value
|
|
41
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
42
|
+
raise ValueError(f"{self.name} must be a number")
|
|
43
|
+
number = min(self.high, max(self.low, float(value)))
|
|
44
|
+
return round(number) if self.integer else number
|
|
45
|
+
|
|
46
|
+
def nudge(self, value: Any, direction: int) -> Any:
|
|
47
|
+
"""The next value up (``direction`` = 1) or down (-1), as the Studio steps it."""
|
|
48
|
+
if self.choices:
|
|
49
|
+
index = self.choices.index(value) if value in self.choices else 0
|
|
50
|
+
return self.choices[(index + direction) % len(self.choices)]
|
|
51
|
+
return self.clean(round(value + direction * self.step, 6))
|
|
52
|
+
|
|
53
|
+
def format(self, value: Any) -> str:
|
|
54
|
+
if self.choices or self.integer:
|
|
55
|
+
return str(value)
|
|
56
|
+
return f"{value:.2f}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Kind:
|
|
61
|
+
"""A kind of layer, warp or filter: its name, its params and how to build it.
|
|
62
|
+
|
|
63
|
+
``build(params, t)`` returns the function for time ``t``: ``(x, y) -> value``
|
|
64
|
+
for layers, ``(x, y) -> (x, y)`` for warps and ``value -> value`` for filters.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
label: str
|
|
69
|
+
build: Callable[[Params, float], Callable]
|
|
70
|
+
params: tuple[Param, ...] = ()
|
|
71
|
+
summary: str = ""
|
|
72
|
+
nouns: tuple[str, ...] = field(default=(), compare=False) # for generated names
|
|
73
|
+
|
|
74
|
+
def defaults(self) -> Params:
|
|
75
|
+
return {p.name: p.default for p in self.params}
|
|
76
|
+
|
|
77
|
+
def clean(self, given: Params) -> Params:
|
|
78
|
+
"""Defaults overlaid with ``given`` (validated); unknown keys are ignored."""
|
|
79
|
+
params = self.defaults()
|
|
80
|
+
for p in self.params:
|
|
81
|
+
if p.name in given:
|
|
82
|
+
params[p.name] = p.clean(given[p.name])
|
|
83
|
+
return params
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def registry(*kinds: Kind) -> dict[str, Kind]:
|
|
87
|
+
return {kind.name: kind for kind in kinds}
|