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,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}