emoji-scripts 0.1.0__tar.gz
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.
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: emoji-scripts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tools for dealing with emojis
|
|
5
|
+
Author: Phil Pennock
|
|
6
|
+
Author-email: Phil Pennock <python-pkgs@pennock-tech.com>
|
|
7
|
+
License-Expression: ISC
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
13
|
+
Requires-Dist: numpy>=1.20.0
|
|
14
|
+
Requires-Dist: pillow>=9.0.0
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
emoji scripts
|
|
19
|
+
=============
|
|
20
|
+
|
|
21
|
+
Tools for making or working with emojis, where emoji is used in the Slack
|
|
22
|
+
sense: short-code named images, up to 128x128px, intended for communication.
|
|
23
|
+
Not, directly, Unicode emoji code-points.
|
|
24
|
+
|
|
25
|
+
Creation of emojis, editing, uploading, syncing, whatever.
|
|
26
|
+
|
|
27
|
+
### Contributions
|
|
28
|
+
|
|
29
|
+
Contributions, both human and AI-written, welcome, as long as a human takes
|
|
30
|
+
responsibility for any given commit: an AI might write it, but if you submit
|
|
31
|
+
it, you're responsible for it.
|
|
32
|
+
|
|
33
|
+
### Licensing
|
|
34
|
+
|
|
35
|
+
ISC.
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
## Coding Practices
|
|
39
|
+
|
|
40
|
+
I have a strong preference for Python or Go for software which needs to be
|
|
41
|
+
maintained, but we'll also take robust shell if it's invoking tools such as
|
|
42
|
+
ImageMagick and is sufficiently short (and passes shellcheck(1)).
|
|
43
|
+
|
|
44
|
+
For Python: please include PEP 723 metadata inline in the script, so that it
|
|
45
|
+
can be invoked without requiring that people install everything in this
|
|
46
|
+
collection. But do also ensure that you have a `main()` function which can be
|
|
47
|
+
used as an entrypoint for when people _do_ install all these scripts.
|
|
48
|
+
|
|
49
|
+
(He says, when there's so far 1 script).
|
|
50
|
+
|
|
51
|
+
There's an `.editorconfig` file to try to get things somewhat consistent.
|
|
52
|
+
|
|
53
|
+
I tend to use `ruff` and `ty` as LSPs when editing. So while I might add
|
|
54
|
+
AI-generated code which is not 100% diagnostic-free, if I go editing then I'll
|
|
55
|
+
fix. As long as you are using sensible types to have code which can be
|
|
56
|
+
reasoned about by those not familiar with it, this is fine for submissions.
|
|
57
|
+
|
|
58
|
+
I use `uv` and am happy to be fairly aggressive in upgrading minimum Python
|
|
59
|
+
versions, rather than get trapped having to support ancient Python.
|
|
60
|
+
|
|
61
|
+
If we need a task runner, it will likely be Task (<https://taskfile.dev/>).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
emoji scripts
|
|
2
|
+
=============
|
|
3
|
+
|
|
4
|
+
Tools for making or working with emojis, where emoji is used in the Slack
|
|
5
|
+
sense: short-code named images, up to 128x128px, intended for communication.
|
|
6
|
+
Not, directly, Unicode emoji code-points.
|
|
7
|
+
|
|
8
|
+
Creation of emojis, editing, uploading, syncing, whatever.
|
|
9
|
+
|
|
10
|
+
### Contributions
|
|
11
|
+
|
|
12
|
+
Contributions, both human and AI-written, welcome, as long as a human takes
|
|
13
|
+
responsibility for any given commit: an AI might write it, but if you submit
|
|
14
|
+
it, you're responsible for it.
|
|
15
|
+
|
|
16
|
+
### Licensing
|
|
17
|
+
|
|
18
|
+
ISC.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Coding Practices
|
|
22
|
+
|
|
23
|
+
I have a strong preference for Python or Go for software which needs to be
|
|
24
|
+
maintained, but we'll also take robust shell if it's invoking tools such as
|
|
25
|
+
ImageMagick and is sufficiently short (and passes shellcheck(1)).
|
|
26
|
+
|
|
27
|
+
For Python: please include PEP 723 metadata inline in the script, so that it
|
|
28
|
+
can be invoked without requiring that people install everything in this
|
|
29
|
+
collection. But do also ensure that you have a `main()` function which can be
|
|
30
|
+
used as an entrypoint for when people _do_ install all these scripts.
|
|
31
|
+
|
|
32
|
+
(He says, when there's so far 1 script).
|
|
33
|
+
|
|
34
|
+
There's an `.editorconfig` file to try to get things somewhat consistent.
|
|
35
|
+
|
|
36
|
+
I tend to use `ruff` and `ty` as LSPs when editing. So while I might add
|
|
37
|
+
AI-generated code which is not 100% diagnostic-free, if I go editing then I'll
|
|
38
|
+
fix. As long as you are using sensible types to have code which can be
|
|
39
|
+
reasoned about by those not familiar with it, this is fine for submissions.
|
|
40
|
+
|
|
41
|
+
I use `uv` and am happy to be fairly aggressive in upgrading minimum Python
|
|
42
|
+
versions, rather than get trapped having to support ancient Python.
|
|
43
|
+
|
|
44
|
+
If we need a task runner, it will likely be Task (<https://taskfile.dev/>).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tools to work with animated emoji"""
|
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.12"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "Pillow>=9.0.0",
|
|
6
|
+
# "numpy>=1.20.0",
|
|
7
|
+
# ]
|
|
8
|
+
# ///
|
|
9
|
+
"""
|
|
10
|
+
waveflag.py — Convert a still image into an animated waving-flag GIF or APNG.
|
|
11
|
+
|
|
12
|
+
Requirements:
|
|
13
|
+
pip install Pillow numpy
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python waveflag.py FLAG.png output.gif
|
|
17
|
+
python waveflag.py FLAG.png output.png --style ripple --fireworks
|
|
18
|
+
python waveflag.py logo.png output.gif --border-color black --amplitude 20
|
|
19
|
+
python waveflag.py photo.jpg output.png --sparkles twinkle --frames 30 --fps 24
|
|
20
|
+
|
|
21
|
+
Output format is inferred from the file extension of OUTPUT:
|
|
22
|
+
.gif Animated GIF (256 colours, binary transparency)
|
|
23
|
+
.png / .apng Animated PNG (full 32-bit RGBA, better quality)
|
|
24
|
+
|
|
25
|
+
Wave styles (default: diagonal):
|
|
26
|
+
diagonal Diagonally spreading ripple — wave crests run diagonally so the
|
|
27
|
+
right-hand free edge flutters realistically. Default.
|
|
28
|
+
wave Classic horizontal sinusoidal wave, pole fixed at left edge.
|
|
29
|
+
ripple 2-D ripple: primary vertical wave + subtle horizontal wriggle.
|
|
30
|
+
flutter Cloth flutter — two superimposed high-frequency waves.
|
|
31
|
+
fold Crumpled-fold look: horizontal pinch plus vertical wave.
|
|
32
|
+
|
|
33
|
+
Sparkle styles (--sparkles):
|
|
34
|
+
twinkle Star-shaped sparkles that appear, brighten, and fade in-place.
|
|
35
|
+
burst 8-point starburst sparks with a sharp flash and slow fade.
|
|
36
|
+
drift Stars that drift upward while twinkling.
|
|
37
|
+
|
|
38
|
+
Border:
|
|
39
|
+
A thin outline is drawn around the warped silhouette. Default "auto"
|
|
40
|
+
picks white on dark images, black on light images.
|
|
41
|
+
|
|
42
|
+
Fireworks:
|
|
43
|
+
--fireworks composites a particle-burst animation behind the flag.
|
|
44
|
+
Works in both GIF (binary transparency) and APNG (smooth alpha fades).
|
|
45
|
+
|
|
46
|
+
Clipping:
|
|
47
|
+
The output canvas is taller than the source by 2 x pad so displacement
|
|
48
|
+
never clips. For wide flags (wider than tall), this gives the flag room
|
|
49
|
+
to move up and down within its bounding box without any cropping.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import argparse
|
|
55
|
+
import colorsys
|
|
56
|
+
import math
|
|
57
|
+
import random
|
|
58
|
+
import sys
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
from typing import Optional
|
|
61
|
+
|
|
62
|
+
import numpy as np
|
|
63
|
+
from PIL import Image, ImageDraw, ImageFilter
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Constants
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
WAVE_STYLES = ("diagonal", "wave", "ripple", "flutter", "fold")
|
|
70
|
+
SPARKLE_STYLES = ("twinkle", "burst", "drift")
|
|
71
|
+
|
|
72
|
+
_WAVE_DOCS = {
|
|
73
|
+
"diagonal": "diagonally spreading ripple, right edge flutters (default)",
|
|
74
|
+
"wave": "smooth sinusoidal wave, pole fixed at left edge",
|
|
75
|
+
"ripple": "2-D ripple with both vertical and horizontal displacement",
|
|
76
|
+
"flutter": "cloth flutter — two superimposed high-frequency waves",
|
|
77
|
+
"fold": "crumpled fold — horizontal pinch + vertical wave",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# CLI
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
86
|
+
p = argparse.ArgumentParser(
|
|
87
|
+
description=__doc__,
|
|
88
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
89
|
+
)
|
|
90
|
+
p.add_argument("input", metavar="INPUT", help="Source image (JPEG/PNG/…)")
|
|
91
|
+
p.add_argument("output", metavar="OUTPUT", help="Output animation (.gif / .png / .apng)")
|
|
92
|
+
|
|
93
|
+
g = p.add_argument_group("wave")
|
|
94
|
+
g.add_argument(
|
|
95
|
+
"--style", choices=WAVE_STYLES, default="diagonal", metavar="STYLE",
|
|
96
|
+
help=("Wave style: "
|
|
97
|
+
+ " ".join(f"{k} ({v})" for k, v in _WAVE_DOCS.items())
|
|
98
|
+
+ " (default: diagonal)"),
|
|
99
|
+
)
|
|
100
|
+
g.add_argument("--amplitude", type=float, default=12.0, metavar="PX",
|
|
101
|
+
help="Peak displacement in pixels (default: 12)")
|
|
102
|
+
g.add_argument("--frequency", type=float, default=1.5, metavar="N",
|
|
103
|
+
help="Spatial cycles across the flag width (default: 1.5)")
|
|
104
|
+
g.add_argument("--speed", type=float, default=0.6, metavar="N",
|
|
105
|
+
help=("Wave cycles per second. The frame count is auto-adjusted "
|
|
106
|
+
"to the nearest multiple of round(fps/speed) so the "
|
|
107
|
+
"animation loops without a jerk regardless of this value. "
|
|
108
|
+
"(default: 0.6)"))
|
|
109
|
+
|
|
110
|
+
g = p.add_argument_group("animation")
|
|
111
|
+
g.add_argument("--frames", type=int, default=24, metavar="N",
|
|
112
|
+
help="Number of frames (default: 24)")
|
|
113
|
+
g.add_argument("--fps", type=int, default=20, metavar="N",
|
|
114
|
+
help="Frames per second (default: 20)")
|
|
115
|
+
g.add_argument("--size", metavar="WxH",
|
|
116
|
+
help="Resize the input image before processing, e.g. 320x200")
|
|
117
|
+
|
|
118
|
+
g = p.add_argument_group("border")
|
|
119
|
+
g.add_argument(
|
|
120
|
+
"--border-color", default="auto", metavar="COLOR",
|
|
121
|
+
help="Outline colour: auto (default), white, black, none, or #rrggbb",
|
|
122
|
+
)
|
|
123
|
+
g.add_argument("--border-width", type=int, default=2, metavar="PX",
|
|
124
|
+
help="Outline thickness in pixels (default: 2)")
|
|
125
|
+
|
|
126
|
+
g = p.add_argument_group("extras")
|
|
127
|
+
g.add_argument("--fireworks", action="store_true",
|
|
128
|
+
help="Composite animated fireworks behind the flag")
|
|
129
|
+
g.add_argument(
|
|
130
|
+
"--sparkles", choices=SPARKLE_STYLES, default=None, metavar="STYLE",
|
|
131
|
+
help="Star sparkles over the front: " + ", ".join(SPARKLE_STYLES),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return p
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Colour utilities
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def mean_luminance(img: Image.Image) -> float:
|
|
142
|
+
a = np.asarray(img.convert("RGB"), dtype=np.float32)
|
|
143
|
+
return float((0.299 * a[:, :, 0]
|
|
144
|
+
+ 0.587 * a[:, :, 1]
|
|
145
|
+
+ 0.114 * a[:, :, 2]).mean())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_border_color(
|
|
149
|
+
spec: str, src: Image.Image
|
|
150
|
+
) -> Optional[tuple[int, int, int]]:
|
|
151
|
+
s = spec.strip().lower()
|
|
152
|
+
if s == "none":
|
|
153
|
+
return None
|
|
154
|
+
if s == "auto":
|
|
155
|
+
return (255, 255, 255) if mean_luminance(src) < 128 else (0, 0, 0)
|
|
156
|
+
if s == "white":
|
|
157
|
+
return (255, 255, 255)
|
|
158
|
+
if s == "black":
|
|
159
|
+
return (0, 0, 0)
|
|
160
|
+
if len(s) == 7 and s[0] == "#":
|
|
161
|
+
try:
|
|
162
|
+
return (int(s[1:3], 16), int(s[3:5], 16), int(s[5:7], 16))
|
|
163
|
+
except ValueError:
|
|
164
|
+
pass
|
|
165
|
+
sys.exit(f"Unknown colour {spec!r}. Use: auto / white / black / none / #rrggbb")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Warp engine
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
#
|
|
172
|
+
# Inverse-warp formula derivation
|
|
173
|
+
# ================================
|
|
174
|
+
# Forward model: source pixel (sx, sy) moves to canvas output position
|
|
175
|
+
#
|
|
176
|
+
# ox = sx + dx(sx, sy)
|
|
177
|
+
# oy = sy + pad + dy(sx, sy) ← '+pad' centres flag vertically
|
|
178
|
+
#
|
|
179
|
+
# Inverse (approximate, treating displacement as function of output coords):
|
|
180
|
+
#
|
|
181
|
+
# map_x[oy, ox] = ox - dx(ox, oy)
|
|
182
|
+
# map_y[oy, ox] = oy - pad - dy(ox, oy)
|
|
183
|
+
#
|
|
184
|
+
# Canvas height = h + 2*pad. Pixels whose source coords fall outside
|
|
185
|
+
# [0,w) x [0,h) are returned as transparent. Because pad >= amplitude,
|
|
186
|
+
# even maximum-displacement edge pixels always land within the canvas.
|
|
187
|
+
|
|
188
|
+
def make_warp_maps(
|
|
189
|
+
w: int, h: int, pad: int,
|
|
190
|
+
frame: int, frames_per_cycle: int,
|
|
191
|
+
style: str, amplitude: float, frequency: float,
|
|
192
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
193
|
+
"""
|
|
194
|
+
Return inverse-warp maps ``(map_x, map_y)`` with shape ``(h + 2*pad, w)``.
|
|
195
|
+
|
|
196
|
+
Loop invariant: phase advances by exactly 2pi every ``frames_per_cycle``
|
|
197
|
+
frames, so any frame count that is a multiple of ``frames_per_cycle``
|
|
198
|
+
produces a perfectly seamless loop — no jerk at the wrap point.
|
|
199
|
+
|
|
200
|
+
``frames_per_cycle`` is computed in ``build_frames`` as
|
|
201
|
+
``round(fps / speed)`` (minimum 2), and the total frame count is rounded
|
|
202
|
+
up to the nearest multiple of ``frames_per_cycle`` before rendering begins.
|
|
203
|
+
"""
|
|
204
|
+
OH = h + 2 * pad
|
|
205
|
+
# phase in [0, 2pi) per wave cycle; resets exactly every frames_per_cycle frames
|
|
206
|
+
phase = (frame / frames_per_cycle) * 2.0 * math.pi
|
|
207
|
+
|
|
208
|
+
# Output-pixel coordinate grids, shape (OH, w)
|
|
209
|
+
gx, gy = np.meshgrid(
|
|
210
|
+
np.arange(w, dtype=np.float32),
|
|
211
|
+
np.arange(OH, dtype=np.float32),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# x-envelope: 0 at pole (left edge) → 1 at free edge (right edge)
|
|
215
|
+
env = gx / max(w - 1, 1)
|
|
216
|
+
|
|
217
|
+
# Normalised y within the flag area, clamped to [0, 1]
|
|
218
|
+
yn = np.clip((gy - pad) / max(h - 1, 1), 0.0, 1.0)
|
|
219
|
+
|
|
220
|
+
TAU = 2.0 * math.pi
|
|
221
|
+
|
|
222
|
+
if style == "diagonal":
|
|
223
|
+
# Diagonal ripple: adding a yn term to the spatial phase tilts the
|
|
224
|
+
# wave crests diagonally, matching the look of a real flag in wind.
|
|
225
|
+
# coefficient 0.30 → ~108° phase shift top-to-bottom.
|
|
226
|
+
dy = amplitude * env * np.sin(TAU * frequency * (env + 0.30 * yn) - phase)
|
|
227
|
+
dx = np.zeros_like(dy)
|
|
228
|
+
|
|
229
|
+
elif style == "wave":
|
|
230
|
+
# Pure vertical wave; crests are vertical lines.
|
|
231
|
+
dy = amplitude * env * np.sin(TAU * frequency * env - phase)
|
|
232
|
+
dx = np.zeros_like(dy)
|
|
233
|
+
|
|
234
|
+
elif style == "ripple":
|
|
235
|
+
# Primary vertical wave + smaller horizontal wriggle at a different
|
|
236
|
+
# spatial frequency so the two axes feel decoupled.
|
|
237
|
+
dy = amplitude * env * np.sin(TAU * frequency * env - phase)
|
|
238
|
+
dx = amplitude * 0.2 * env * np.sin(TAU * frequency * 0.7 * env - phase + 1.1)
|
|
239
|
+
|
|
240
|
+
elif style == "flutter":
|
|
241
|
+
# Two incommensurable frequencies → irregular, cloth-like motion.
|
|
242
|
+
dy = (amplitude * 0.55 * env * np.sin(TAU * frequency * 1.8 * env - phase)
|
|
243
|
+
+ amplitude * 0.45 * env * np.sin(TAU * frequency * 3.5 * env - phase * 2.2))
|
|
244
|
+
dx = np.zeros_like(dy)
|
|
245
|
+
|
|
246
|
+
elif style == "fold":
|
|
247
|
+
# Horizontal pinch + stretched vertical wave → stiff fabric folding.
|
|
248
|
+
dx = amplitude * 0.35 * env * np.sin(TAU * frequency * env - phase + 0.7)
|
|
249
|
+
dy = amplitude * 0.75 * env * np.sin(TAU * frequency * 1.1 * env - phase)
|
|
250
|
+
|
|
251
|
+
else:
|
|
252
|
+
sys.exit(f"Unknown style {style!r}. Choices: {WAVE_STYLES}")
|
|
253
|
+
|
|
254
|
+
return gx - dx, gy - pad - dy
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def warp_rgba(
|
|
258
|
+
src: np.ndarray,
|
|
259
|
+
map_x: np.ndarray,
|
|
260
|
+
map_y: np.ndarray,
|
|
261
|
+
) -> np.ndarray:
|
|
262
|
+
"""
|
|
263
|
+
Bilinear inverse-warp of RGBA *src* (H x W x 4, uint8).
|
|
264
|
+
|
|
265
|
+
*map_x* / *map_y* may have a larger shape than *src*; pixels whose source
|
|
266
|
+
coordinates fall outside [0,W) x [0,H) become fully-transparent black.
|
|
267
|
+
"""
|
|
268
|
+
h, w = src.shape[:2]
|
|
269
|
+
src_f = src.astype(np.float32)
|
|
270
|
+
|
|
271
|
+
x0 = np.floor(map_x).astype(np.int32)
|
|
272
|
+
y0 = np.floor(map_y).astype(np.int32)
|
|
273
|
+
x1 = x0 + 1
|
|
274
|
+
y1 = y0 + 1
|
|
275
|
+
|
|
276
|
+
fx = (map_x - x0)[..., np.newaxis] # fractional parts (OH, OW, 1)
|
|
277
|
+
fy = (map_y - y0)[..., np.newaxis]
|
|
278
|
+
|
|
279
|
+
oob = (map_x < 0) | (map_x > w - 1) | (map_y < 0) | (map_y > h - 1)
|
|
280
|
+
|
|
281
|
+
x0c = np.clip(x0, 0, w - 1); x1c = np.clip(x1, 0, w - 1)
|
|
282
|
+
y0c = np.clip(y0, 0, h - 1); y1c = np.clip(y1, 0, h - 1)
|
|
283
|
+
|
|
284
|
+
c00 = src_f[y0c, x0c]; c10 = src_f[y0c, x1c]
|
|
285
|
+
c01 = src_f[y1c, x0c]; c11 = src_f[y1c, x1c]
|
|
286
|
+
|
|
287
|
+
blended = (c00 * (1 - fx) * (1 - fy)
|
|
288
|
+
+ c10 * fx * (1 - fy)
|
|
289
|
+
+ c01 * (1 - fx) * fy
|
|
290
|
+
+ c11 * fx * fy)
|
|
291
|
+
|
|
292
|
+
out = np.rint(blended).astype(np.uint8)
|
|
293
|
+
out[oob] = 0
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
# Border / outline
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def apply_border(
|
|
302
|
+
warped: np.ndarray,
|
|
303
|
+
color: tuple[int, int, int],
|
|
304
|
+
width: int,
|
|
305
|
+
) -> np.ndarray:
|
|
306
|
+
"""Morphologically dilate the alpha silhouette to draw a *width*-px outline."""
|
|
307
|
+
alpha_img = Image.fromarray(warped[:, :, 3], "L")
|
|
308
|
+
dilated = alpha_img.filter(ImageFilter.MaxFilter(size=2 * width + 1))
|
|
309
|
+
|
|
310
|
+
d = np.asarray(dilated)
|
|
311
|
+
a = warped[:, :, 3]
|
|
312
|
+
bm = (d > 0) & (a == 0) # ring between dilation and original alpha
|
|
313
|
+
|
|
314
|
+
out = warped.copy()
|
|
315
|
+
out[bm, 0] = color[0]
|
|
316
|
+
out[bm, 1] = color[1]
|
|
317
|
+
out[bm, 2] = color[2]
|
|
318
|
+
out[bm, 3] = 255
|
|
319
|
+
return out
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# Fireworks particle system
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
class _Firework:
|
|
327
|
+
GRAVITY = 0.18
|
|
328
|
+
DRAG = 0.97
|
|
329
|
+
N_PARTS = 50
|
|
330
|
+
|
|
331
|
+
def __init__(self, w: int, h: int) -> None:
|
|
332
|
+
self.cx = random.uniform(0.10 * w, 0.90 * w)
|
|
333
|
+
self.cy = random.uniform(0.04 * h, 0.50 * h)
|
|
334
|
+
hue = random.random()
|
|
335
|
+
self.rgb = tuple(int(v * 255) for v in colorsys.hsv_to_rgb(hue, 1.0, 1.0))
|
|
336
|
+
self.pts: list[list[float]] = []
|
|
337
|
+
for _ in range(self.N_PARTS):
|
|
338
|
+
angle = random.uniform(0, 2 * math.pi)
|
|
339
|
+
spd = max(0.4, random.gauss(3.2, 1.0))
|
|
340
|
+
self.pts.append([self.cx, self.cy,
|
|
341
|
+
spd * math.cos(angle), spd * math.sin(angle), 1.0])
|
|
342
|
+
self.age = 0
|
|
343
|
+
self.max_age = random.randint(16, 30)
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def alive(self) -> bool:
|
|
347
|
+
return self.age < self.max_age
|
|
348
|
+
|
|
349
|
+
def tick(self) -> None:
|
|
350
|
+
self.age += 1
|
|
351
|
+
t = self.age / self.max_age
|
|
352
|
+
for p in self.pts:
|
|
353
|
+
p[0] += p[2]; p[1] += p[3]
|
|
354
|
+
p[3] += self.GRAVITY
|
|
355
|
+
p[2] *= self.DRAG; p[3] *= self.DRAG
|
|
356
|
+
p[4] = max(0.0, 1.0 - t ** 1.3)
|
|
357
|
+
|
|
358
|
+
def draw(self, draw: ImageDraw.ImageDraw, gif_mode: bool) -> None:
|
|
359
|
+
r, g, b = self.rgb # type: ignore[misc]
|
|
360
|
+
for p in self.pts:
|
|
361
|
+
life = p[4]
|
|
362
|
+
if life <= 0 or (gif_mode and life < 0.30):
|
|
363
|
+
continue
|
|
364
|
+
alpha = 255 if gif_mode else int(life * 220)
|
|
365
|
+
x, y = int(p[0]), int(p[1])
|
|
366
|
+
draw.ellipse([x - 1, y - 1, x + 1, y + 1], fill=(r, g, b, alpha))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class _FireworkSystem:
|
|
370
|
+
def __init__(self, w: int, h: int, n_frames: int) -> None:
|
|
371
|
+
self.w, self.h = w, h
|
|
372
|
+
self.spawn_p = min(0.5, 8.0 / max(n_frames, 1))
|
|
373
|
+
self.active: list[_Firework] = []
|
|
374
|
+
for _ in range(random.randint(1, 3)):
|
|
375
|
+
fw = _Firework(w, h)
|
|
376
|
+
for _ in range(random.randint(0, max(0, fw.max_age - 1))):
|
|
377
|
+
fw.tick()
|
|
378
|
+
if fw.alive:
|
|
379
|
+
self.active.append(fw)
|
|
380
|
+
|
|
381
|
+
def tick(self) -> None:
|
|
382
|
+
for fw in self.active:
|
|
383
|
+
fw.tick()
|
|
384
|
+
self.active = [fw for fw in self.active if fw.alive]
|
|
385
|
+
if random.random() < self.spawn_p:
|
|
386
|
+
self.active.append(_Firework(self.w, self.h))
|
|
387
|
+
|
|
388
|
+
def render(self, size: tuple[int, int], gif_mode: bool) -> Image.Image:
|
|
389
|
+
layer = Image.new("RGBA", size, (0, 0, 0, 0))
|
|
390
|
+
draw = ImageDraw.Draw(layer, "RGBA")
|
|
391
|
+
for fw in self.active:
|
|
392
|
+
fw.draw(draw, gif_mode)
|
|
393
|
+
return layer
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
# Sparkle system
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
def _draw_4star(
|
|
401
|
+
draw: ImageDraw.ImageDraw,
|
|
402
|
+
cx: float, cy: float,
|
|
403
|
+
r_outer: float, r_inner: float,
|
|
404
|
+
angle_offset: float,
|
|
405
|
+
color: tuple,
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Draw a 4-point star polygon centred at (cx, cy)."""
|
|
408
|
+
pts = []
|
|
409
|
+
for i in range(8):
|
|
410
|
+
r = r_outer if i % 2 == 0 else r_inner
|
|
411
|
+
a = math.pi * i / 4 + angle_offset
|
|
412
|
+
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
|
|
413
|
+
draw.polygon(pts, fill=color)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
_SPARKLE_COLORS: list[tuple[int, int, int]] = [
|
|
417
|
+
(255, 255, 220), # warm white
|
|
418
|
+
(220, 240, 255), # cool blue-white
|
|
419
|
+
(255, 255, 160), # bright yellow
|
|
420
|
+
(255, 210, 255), # soft pink
|
|
421
|
+
(180, 255, 255), # cyan
|
|
422
|
+
(255, 255, 255), # pure white
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class _SparkleSystem:
|
|
427
|
+
"""
|
|
428
|
+
Pre-seeded star sparkles composited over the flag on every frame.
|
|
429
|
+
|
|
430
|
+
All sparkles loop seamlessly: their brightness envelopes are based on
|
|
431
|
+
``frame / n_frames`` so frame 0 == frame n_frames.
|
|
432
|
+
|
|
433
|
+
Sparkle positions are generated within the flag area of the padded canvas
|
|
434
|
+
(y ∈ [pad, pad+flag_h)).
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
def __init__(
|
|
438
|
+
self,
|
|
439
|
+
flag_w: int, flag_h: int,
|
|
440
|
+
canvas_h: int, pad: int,
|
|
441
|
+
n_frames: int, style: str,
|
|
442
|
+
) -> None:
|
|
443
|
+
self.flag_w = flag_w
|
|
444
|
+
self.canvas_h = canvas_h
|
|
445
|
+
self.pad = pad
|
|
446
|
+
self.flag_h = flag_h
|
|
447
|
+
self.n_frames = n_frames
|
|
448
|
+
self.style = style
|
|
449
|
+
|
|
450
|
+
n = max(8, min(40, (flag_w * flag_h) // 500))
|
|
451
|
+
|
|
452
|
+
self.sparkles: list[dict] = []
|
|
453
|
+
for _ in range(n):
|
|
454
|
+
x = random.uniform(0.05 * flag_w, 0.95 * flag_w)
|
|
455
|
+
y = random.uniform(pad + 0.05 * flag_h, pad + 0.95 * flag_h)
|
|
456
|
+
sp: dict = {
|
|
457
|
+
"x0": x,
|
|
458
|
+
"y0": y,
|
|
459
|
+
"color": random.choice(_SPARKLE_COLORS),
|
|
460
|
+
"max_r": random.uniform(3.5, 9.0),
|
|
461
|
+
"phase": random.uniform(0.0, 2.0 * math.pi),
|
|
462
|
+
"period": random.uniform(0.4, 2.2),
|
|
463
|
+
}
|
|
464
|
+
if style == "drift":
|
|
465
|
+
sp["vx"] = random.uniform(-0.25, 0.25)
|
|
466
|
+
sp["vy"] = random.uniform(-1.0, -0.3) # upward
|
|
467
|
+
self.sparkles.append(sp)
|
|
468
|
+
|
|
469
|
+
def get_layer(self, frame: int, size: tuple[int, int], gif_mode: bool) -> Image.Image:
|
|
470
|
+
layer = Image.new("RGBA", size, (0, 0, 0, 0))
|
|
471
|
+
draw = ImageDraw.Draw(layer, "RGBA")
|
|
472
|
+
t = frame / max(self.n_frames, 1) # ∈ [0, 1)
|
|
473
|
+
|
|
474
|
+
for sp in self.sparkles:
|
|
475
|
+
color = sp["color"]
|
|
476
|
+
phase = sp["phase"]
|
|
477
|
+
period = sp["period"]
|
|
478
|
+
max_r = sp["max_r"]
|
|
479
|
+
|
|
480
|
+
if self.style == "twinkle":
|
|
481
|
+
raw = math.sin(2 * math.pi * period * t + phase)
|
|
482
|
+
b = max(0.0, raw) ** 1.5 # positive half only, nonlinear
|
|
483
|
+
if b < 0.04:
|
|
484
|
+
continue
|
|
485
|
+
r = max_r * b
|
|
486
|
+
alpha = 255 if gif_mode else int(b * 255)
|
|
487
|
+
col = (*color, alpha)
|
|
488
|
+
_draw_4star(draw, sp["x0"], sp["y0"], r, r * 0.28, -math.pi / 4, col)
|
|
489
|
+
cr = r * 0.28
|
|
490
|
+
draw.ellipse(
|
|
491
|
+
[sp["x0"] - cr, sp["y0"] - cr, sp["x0"] + cr, sp["y0"] + cr],
|
|
492
|
+
fill=col,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
elif self.style == "burst":
|
|
496
|
+
raw = math.sin(2 * math.pi * period * t + phase)
|
|
497
|
+
b = max(0.0, raw) ** 0.6 # sharper attack than twinkle
|
|
498
|
+
if b < 0.04:
|
|
499
|
+
continue
|
|
500
|
+
r = max_r * b
|
|
501
|
+
alpha = 255 if gif_mode else int(b * 240)
|
|
502
|
+
col = (*color, alpha)
|
|
503
|
+
# Two 4-point stars at 45° offset → 8-point starburst
|
|
504
|
+
_draw_4star(draw, sp["x0"], sp["y0"], r, r * 0.18, -math.pi / 4, col)
|
|
505
|
+
_draw_4star(draw, sp["x0"], sp["y0"], r * 0.75, r * 0.18, 0.0, col)
|
|
506
|
+
cr = r * 0.25
|
|
507
|
+
draw.ellipse(
|
|
508
|
+
[sp["x0"] - cr, sp["y0"] - cr, sp["x0"] + cr, sp["y0"] + cr],
|
|
509
|
+
fill=(255, 255, 255, alpha),
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
elif self.style == "drift":
|
|
513
|
+
steps = t * self.n_frames
|
|
514
|
+
x = (sp["x0"] + sp["vx"] * steps) % self.flag_w
|
|
515
|
+
y_raw = sp["y0"] + sp["vy"] * steps
|
|
516
|
+
# Wrap y within the flag area of the canvas
|
|
517
|
+
top = self.pad
|
|
518
|
+
bot = self.pad + self.flag_h
|
|
519
|
+
fh = bot - top
|
|
520
|
+
y = top + (y_raw - top) % fh if fh > 0 else y_raw
|
|
521
|
+
raw = (math.sin(2 * math.pi * period * t + phase) + 1) / 2
|
|
522
|
+
b = raw ** 1.3
|
|
523
|
+
r = max_r * max(0.25, b)
|
|
524
|
+
alpha = 255 if gif_mode else int(b * 220)
|
|
525
|
+
col = (*color, alpha)
|
|
526
|
+
_draw_4star(draw, x, y, r, r * 0.28, -math.pi / 4, col)
|
|
527
|
+
|
|
528
|
+
return layer
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ---------------------------------------------------------------------------
|
|
532
|
+
# GIF palette conversion
|
|
533
|
+
# ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
_GIF_TRANSP = 255 # palette slot reserved for transparent pixels
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _rgba_to_gif_palette(rgba: Image.Image) -> Image.Image:
|
|
539
|
+
alpha = np.asarray(rgba.split()[3])
|
|
540
|
+
q = rgba.convert("RGB").quantize(colors=_GIF_TRANSP, dither=0)
|
|
541
|
+
pal = list(q.getpalette())[: _GIF_TRANSP * 3] + [0, 0, 0]
|
|
542
|
+
arr = np.asarray(q, dtype=np.uint8).copy()
|
|
543
|
+
arr[alpha < 128] = _GIF_TRANSP
|
|
544
|
+
out = Image.fromarray(arr, "P")
|
|
545
|
+
out.putpalette(pal)
|
|
546
|
+
return out
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# ---------------------------------------------------------------------------
|
|
550
|
+
# Frame assembly
|
|
551
|
+
# ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
def build_frames(src: Image.Image, args: argparse.Namespace) -> list[Image.Image]:
|
|
554
|
+
"""
|
|
555
|
+
Build all animation frames.
|
|
556
|
+
|
|
557
|
+
Canvas height = flag_h + 2 * pad.
|
|
558
|
+
The warp maps are generated in output space, so the '-pad' in the
|
|
559
|
+
map_y formula already centres the flag. There is no separate paste step:
|
|
560
|
+
``warp_rgba`` produces an array exactly (canvas_h, flag_w) that can be
|
|
561
|
+
alpha-composited directly onto the canvas.
|
|
562
|
+
"""
|
|
563
|
+
w, h = src.size
|
|
564
|
+
pad = int(args.amplitude) + args.border_width + 4
|
|
565
|
+
CW, CH = w, h + 2 * pad
|
|
566
|
+
|
|
567
|
+
src_arr = np.asarray(src.convert("RGBA"), dtype=np.uint8).copy()
|
|
568
|
+
border_col = resolve_border_color(args.border_color, src)
|
|
569
|
+
is_gif = Path(args.output).suffix.lower() == ".gif"
|
|
570
|
+
|
|
571
|
+
# ── Guarantee a seamless loop ─────────────────────────────────────────────
|
|
572
|
+
# One wave cycle occupies exactly frames_per_cycle frames. The total frame
|
|
573
|
+
# count is rounded up to the nearest multiple so the animation always ends
|
|
574
|
+
# one frame's motion before the start — no jerk at the wrap point.
|
|
575
|
+
# Derivation: phase(frame) = frame/frames_per_cycle * 2π
|
|
576
|
+
# → phase(frames_per_cycle) = 2π ≡ phase(0) ✓
|
|
577
|
+
# → any integer multiple of frames_per_cycle also loops cleanly ✓
|
|
578
|
+
frames_per_cycle = max(2, round(args.fps / args.speed))
|
|
579
|
+
n_cycles = max(1, math.ceil(args.frames / frames_per_cycle))
|
|
580
|
+
n_frames = n_cycles * frames_per_cycle
|
|
581
|
+
if n_frames != args.frames:
|
|
582
|
+
print(f" (frames {args.frames} → {n_frames}: "
|
|
583
|
+
f"{n_cycles} cycle(s) × {frames_per_cycle} frames/cycle "
|
|
584
|
+
f"for seamless loop)")
|
|
585
|
+
|
|
586
|
+
fw_sys = (_FireworkSystem(CW, CH, n_frames) if args.fireworks else None)
|
|
587
|
+
sp_sys = (
|
|
588
|
+
_SparkleSystem(w, h, CH, pad, n_frames, args.sparkles)
|
|
589
|
+
if args.sparkles else None
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
frames: list[Image.Image] = []
|
|
593
|
+
|
|
594
|
+
for i in range(n_frames):
|
|
595
|
+
print(f"\r Building frame {i + 1}/{n_frames} …", end="", flush=True)
|
|
596
|
+
|
|
597
|
+
# Warp maps have shape (CH, CW) — the full padded canvas height
|
|
598
|
+
mx, my = make_warp_maps(
|
|
599
|
+
w, h, pad, i, frames_per_cycle,
|
|
600
|
+
args.style, args.amplitude, args.frequency,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
# warped is already (CH, CW, 4): no paste-with-offset needed
|
|
604
|
+
warped = warp_rgba(src_arr, mx, my)
|
|
605
|
+
|
|
606
|
+
if border_col is not None:
|
|
607
|
+
warped = apply_border(warped, border_col, args.border_width)
|
|
608
|
+
|
|
609
|
+
canvas = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
|
|
610
|
+
|
|
611
|
+
# Fireworks behind the flag
|
|
612
|
+
if fw_sys is not None:
|
|
613
|
+
fw_sys.tick()
|
|
614
|
+
canvas = Image.alpha_composite(canvas, fw_sys.render((CW, CH), is_gif))
|
|
615
|
+
|
|
616
|
+
# Flag (already correct size — alpha_composite, not paste)
|
|
617
|
+
canvas = Image.alpha_composite(canvas, Image.fromarray(warped, "RGBA"))
|
|
618
|
+
|
|
619
|
+
# Sparkles in front of the flag
|
|
620
|
+
if sp_sys is not None:
|
|
621
|
+
canvas = Image.alpha_composite(
|
|
622
|
+
canvas, sp_sys.get_layer(i, (CW, CH), is_gif))
|
|
623
|
+
|
|
624
|
+
frames.append(canvas)
|
|
625
|
+
|
|
626
|
+
print()
|
|
627
|
+
return frames
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
# ---------------------------------------------------------------------------
|
|
631
|
+
# Output savers
|
|
632
|
+
# ---------------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
def _save_apng(frames: list[Image.Image], path: str, fps: int) -> None:
|
|
635
|
+
ms = int(1000 / fps)
|
|
636
|
+
frames[0].save(
|
|
637
|
+
path, format="PNG", save_all=True, append_images=frames[1:],
|
|
638
|
+
loop=0, duration=ms,
|
|
639
|
+
)
|
|
640
|
+
print(f"✓ APNG saved: {path} ({len(frames)} frames @ {fps} fps, {ms} ms/frame)")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _save_gif(frames: list[Image.Image], path: str, fps: int) -> None:
|
|
644
|
+
ms = int(1000 / fps)
|
|
645
|
+
print(" Converting to palette …", end="", flush=True)
|
|
646
|
+
pal = [_rgba_to_gif_palette(f) for f in frames]
|
|
647
|
+
print("\r Writing GIF … ", end="", flush=True)
|
|
648
|
+
pal[0].save(
|
|
649
|
+
path, format="GIF", save_all=True, append_images=pal[1:],
|
|
650
|
+
loop=0, duration=ms,
|
|
651
|
+
transparency=_GIF_TRANSP, disposal=2, optimize=False,
|
|
652
|
+
)
|
|
653
|
+
print(f"\r✓ GIF saved: {path} ({len(frames)} frames @ {fps} fps, {ms} ms/frame)")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
# Entry point
|
|
658
|
+
# ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
def main() -> None:
|
|
661
|
+
args = build_parser().parse_args()
|
|
662
|
+
|
|
663
|
+
try:
|
|
664
|
+
src = Image.open(args.input)
|
|
665
|
+
except FileNotFoundError:
|
|
666
|
+
sys.exit(f"Error: cannot open {args.input!r}")
|
|
667
|
+
|
|
668
|
+
# If the input is animated, use only the first frame
|
|
669
|
+
try:
|
|
670
|
+
src.seek(0)
|
|
671
|
+
except (AttributeError, EOFError):
|
|
672
|
+
pass
|
|
673
|
+
src = src.copy()
|
|
674
|
+
|
|
675
|
+
if args.size:
|
|
676
|
+
try:
|
|
677
|
+
rw, rh = (int(d) for d in args.size.lower().split("x", 1))
|
|
678
|
+
except ValueError:
|
|
679
|
+
sys.exit("Error: --size must be WxH, e.g. 320x200")
|
|
680
|
+
src = src.resize((rw, rh), Image.LANCZOS)
|
|
681
|
+
|
|
682
|
+
print(f"Input : {args.input} ({src.width}×{src.height})")
|
|
683
|
+
print(f"Output: {args.output}")
|
|
684
|
+
print(f" style={args.style} amplitude={args.amplitude} "
|
|
685
|
+
f"frequency={args.frequency} speed={args.speed}")
|
|
686
|
+
print(f" frames={args.frames} fps={args.fps} "
|
|
687
|
+
f"border={args.border_color}/{args.border_width}px"
|
|
688
|
+
+ (" fireworks=yes" if args.fireworks else "")
|
|
689
|
+
+ (f" sparkles={args.sparkles}" if args.sparkles else ""))
|
|
690
|
+
|
|
691
|
+
frames = build_frames(src, args)
|
|
692
|
+
|
|
693
|
+
ext = Path(args.output).suffix.lower()
|
|
694
|
+
if ext == ".gif":
|
|
695
|
+
_save_gif(frames, args.output, args.fps)
|
|
696
|
+
elif ext in (".png", ".apng"):
|
|
697
|
+
_save_apng(frames, args.output, args.fps)
|
|
698
|
+
else:
|
|
699
|
+
sys.exit(
|
|
700
|
+
f"Error: unknown output extension {ext!r}. Use .gif, .png, or .apng"
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
if __name__ == "__main__":
|
|
705
|
+
main()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "emoji-scripts"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Tools for dealing with emojis"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "ISC"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name="Phil Pennock", email="python-pkgs@pennock-tech.com" },
|
|
9
|
+
]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Operating System :: OS Independent",
|
|
13
|
+
"Intended Audience :: End Users/Desktop",
|
|
14
|
+
"Intended Audience :: System Administrators",
|
|
15
|
+
"Topic :: Multimedia :: Graphics",
|
|
16
|
+
|
|
17
|
+
# PEP 639 deprecates license in this list
|
|
18
|
+
#"License :: OSI Approved :: ISC License (ISCL)",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
requires-python = ">=3.14"
|
|
22
|
+
|
|
23
|
+
dependencies = [
|
|
24
|
+
"numpy>=1.20.0",
|
|
25
|
+
"pillow>=9.0.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
waveflag = "animation.waveflag:main"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires=["uv_build<=0.11.0"]
|
|
33
|
+
build-backend = "uv_build"
|
|
34
|
+
|
|
35
|
+
[tool.uv.build-backend]
|
|
36
|
+
module-name = ["animation"]
|
|
37
|
+
module-root = ""
|
|
38
|
+
|