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
+