oledgifstudio 0.1.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.
oledgif/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """oledgif — générateur de GIFs animés pour écrans OLED (SteelSeries, SSD1306, ...)."""
2
+
3
+ __version__ = "0.1.0"
oledgif/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
oledgif/cli.py ADDED
@@ -0,0 +1,176 @@
1
+ """CLI : python -m oledgif "TEXTE" [options]"""
2
+
3
+ import argparse
4
+ import os
5
+ import random
6
+ import string
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .describe import parse as parse_description
11
+ from .effects import ALNUM, DESCRIPTIONS, EFFECTS, Ctx
12
+ from .fonts import load_font
13
+ from .patterns import PATTERNS
14
+ from .presets import DEFAULT_PRESET, PRESETS, resolve_size
15
+ from .render import convert_animation, load_source, save_gif
16
+
17
+ from PIL import Image
18
+
19
+ CHARSETS = {
20
+ "alnum": ALNUM,
21
+ "digits": string.digits,
22
+ "upper": string.digits + string.ascii_uppercase,
23
+ "letters": string.ascii_letters,
24
+ "ascii": "".join(c for c in string.printable if c.isprintable() and c != " "),
25
+ }
26
+
27
+
28
+ def build_parser():
29
+ p = argparse.ArgumentParser(
30
+ prog="oledgif",
31
+ description="Génère des GIFs animés 1 bit pour écrans OLED (SteelSeries, SSD1306...).",
32
+ )
33
+ p.add_argument("text", nargs="?", help="texte à animer (optionnel avec --image ou un motif)")
34
+ p.add_argument("-d", "--describe", metavar="PHRASE",
35
+ help="décrire l'animation en langage naturel, ex: 'GG qui clignote vite'")
36
+ p.add_argument("-i", "--image", metavar="FICHIER",
37
+ help="image source (PNG/JPG/GIF...) : convertie en 1 bit avec tramage ; "
38
+ "un GIF animé est converti tel quel si --effect vaut auto/convert")
39
+ p.add_argument("--style", default="photo", choices=["photo", "solid", "comic", "edges"],
40
+ help="rendu de --image : photo = contraste+netteté+tramage (défaut), "
41
+ "solid = seuillage net (logos), comic = solid + contours blancs "
42
+ "des formes perdues dans le noir (illustrations), "
43
+ "edges = contours blancs sur noir")
44
+ p.add_argument("--fit", default="contain", choices=["contain", "cover"],
45
+ help="contain = image entière (défaut), cover = remplit l'écran en "
46
+ "recadrant — recommandé pour les photos paysage")
47
+ p.add_argument("--no-dither", action="store_true",
48
+ help="alias de --style solid")
49
+ p.add_argument("--no-denoise", action="store_true",
50
+ help="garde le bruit : désactive le filtre médian et la suppression "
51
+ "des pixels isolés (reflets, sources de lumière ponctuelles)")
52
+ p.add_argument("-o", "--out", default=None, help="fichier de sortie (.gif)")
53
+ p.add_argument("-p", "--preset", default=None,
54
+ help=f"écran cible (défaut: {DEFAULT_PRESET}) — voir --list-presets")
55
+ p.add_argument("--size", default=None, metavar="WxH", help="taille custom, ex: 128x40")
56
+ p.add_argument("-e", "--effect", default="auto",
57
+ help="effet (défaut: auto) — voir --list-effects")
58
+ p.add_argument("--fps", type=int, default=15)
59
+ p.add_argument("--seconds", type=float, default=4.0, help="durée cible (certains effets la déduisent)")
60
+ p.add_argument("--speed", type=float, default=None, help="vitesse de défilement en px/s (scroll)")
61
+ p.add_argument("--font", default=None, help="chemin d'une police .ttf")
62
+ p.add_argument("--font-size", type=int, default=None)
63
+ p.add_argument("--charset", default="alnum",
64
+ help="alnum|digits|upper|letters|ascii ou chaîne littérale (matrix/slot)")
65
+ p.add_argument("--invert", action="store_true", help="noir sur blanc")
66
+ p.add_argument("--scale", type=int, default=1, help="agrandit le GIF xN (aperçu)")
67
+ p.add_argument("--seed", type=int, default=None, help="graine aléatoire (rendu reproductible)")
68
+ p.add_argument("--demo", action="store_true", help="génère un exemple de chaque effet dans ./samples")
69
+ p.add_argument("--list-effects", action="store_true")
70
+ p.add_argument("--list-presets", action="store_true")
71
+ p.add_argument("--version", action="version", version=f"oledgif {__version__}")
72
+ return p
73
+
74
+
75
+ def make_gif(text, out, *, preset=None, size=None, effect="auto", fps=15, seconds=4.0,
76
+ speed=None, font_path=None, font_size=None, charset=ALNUM,
77
+ invert=False, scale=1, seed=None, image=None, style="photo", fit="contain",
78
+ denoise=True):
79
+ W, H = resolve_size(preset, size)
80
+ font = load_font(font_path, font_size, screen_h=H)
81
+
82
+ src = None
83
+ if image:
84
+ im = Image.open(image)
85
+ if getattr(im, "is_animated", False) and effect in ("auto", "convert"):
86
+ # GIF animé -> conversion frame par frame, durées d'origine conservées
87
+ frames, durations = convert_animation(image, W, H, style, fit, denoise)
88
+ n, duration = save_gif(frames, out, fps=fps, invert=invert,
89
+ scale=scale, durations=durations)
90
+ return out, "convert", W, H, n, duration
91
+ im.close()
92
+ src = load_source(image, W, H, style, fit, denoise)
93
+ if effect in ("auto", "convert"):
94
+ effect = "vhs"
95
+
96
+ if effect == "auto":
97
+ try:
98
+ wide = font.getlength(text) > W - 4
99
+ except AttributeError:
100
+ wide = len(text) * 7 > W - 4
101
+ effect = "scroll" if wide else "wave"
102
+ if effect not in EFFECTS:
103
+ raise SystemExit(f"Effet inconnu: {effect!r}. Effets: {', '.join(EFFECTS)}")
104
+
105
+ ctx = Ctx(text=text or "", W=W, H=H, fps=fps, seconds=seconds, font=font,
106
+ font_path=font_path, font_size=font_size, charset=charset,
107
+ speed=speed, src=src, rng=random.Random(seed))
108
+ frames = EFFECTS[effect](ctx)
109
+ n, duration = save_gif(frames, out, fps=fps, invert=invert, scale=scale)
110
+ return out, effect, W, H, n, duration
111
+
112
+
113
+ def main(argv=None):
114
+ args = build_parser().parse_args(argv)
115
+
116
+ if args.list_effects:
117
+ for name, desc in DESCRIPTIONS.items():
118
+ print(f" {name:<11} {desc}")
119
+ return 0
120
+ if args.list_presets:
121
+ for name, (w, h, desc) in PRESETS.items():
122
+ print(f" {name:<12} {w}x{h:<4} {desc}")
123
+ return 0
124
+
125
+ charset = CHARSETS.get(args.charset, args.charset)
126
+
127
+ if args.demo:
128
+ outdir = "samples"
129
+ os.makedirs(outdir, exist_ok=True)
130
+ text = args.text or "GG WP 42"
131
+ for name in EFFECTS:
132
+ out = os.path.join(outdir, f"{name}.gif")
133
+ make_gif(text, out, preset=args.preset, size=args.size, effect=name,
134
+ fps=args.fps, seconds=args.seconds, speed=args.speed,
135
+ font_path=args.font, font_size=args.font_size, charset=charset,
136
+ invert=args.invert, scale=args.scale, seed=args.seed or 42)
137
+ print(f" {out}")
138
+ return 0
139
+
140
+ text, effect, fps, seconds = args.text, args.effect, args.fps, args.seconds
141
+ if args.describe:
142
+ parsed = parse_description(args.describe)
143
+ text = parsed.get("text", text)
144
+ if effect == "auto":
145
+ effect = parsed.get("effect", "auto")
146
+ fps = parsed.get("fps", fps)
147
+ seconds = parsed.get("seconds", seconds)
148
+
149
+ textless_ok = args.image or effect in PATTERNS or effect == "matrix"
150
+ if not text and not textless_ok:
151
+ print("Erreur: aucun texte. Passe-le en argument, entre guillemets dans --describe, "
152
+ "ou utilise --image / un motif (starfield, plasma, life, eq, scope, radar).",
153
+ file=sys.stderr)
154
+ return 2
155
+
156
+ if args.out:
157
+ out = args.out
158
+ elif text:
159
+ out = "".join(c if c.isalnum() else "_" for c in text)[:24] + ".gif"
160
+ elif args.image:
161
+ out = os.path.splitext(os.path.basename(args.image))[0] + "_oled.gif"
162
+ else:
163
+ out = f"{effect}.gif"
164
+ out, effect, W, H, n, duration = make_gif(
165
+ text, out, preset=args.preset, size=args.size, effect=effect, fps=fps,
166
+ seconds=seconds, speed=args.speed, font_path=args.font,
167
+ font_size=args.font_size, charset=charset, invert=args.invert,
168
+ scale=args.scale, seed=args.seed, image=args.image,
169
+ style="solid" if args.no_dither else args.style, fit=args.fit,
170
+ denoise=not args.no_denoise)
171
+ print(f"{out} — {W}x{H}, effet {effect}, {n} frames @ {duration} ms")
172
+ return 0
173
+
174
+
175
+ if __name__ == "__main__":
176
+ raise SystemExit(main())
oledgif/describe.py ADDED
@@ -0,0 +1,60 @@
1
+ """Mode langage naturel (FR/EN) : une phrase -> effet + paramètres.
2
+
3
+ Pas de modèle IA : simple correspondance de mots-clés, instantané et gratuit.
4
+ """
5
+
6
+ import re
7
+ import unicodedata
8
+
9
+ KEYWORDS = {
10
+ "scroll": ["defile", "defilant", "scroll", "marquee", "bandeau", "glisse a gauche"],
11
+ "typewriter": ["machine a ecrire", "typewriter", "tape", "frappe", "ecrit lettre"],
12
+ "wave": ["vague", "wave", "ondule", "ondulation", "flotte"],
13
+ "blink": ["clignote", "clignotant", "blink", "flash", "flashe"],
14
+ "bounce": ["rebond", "rebondit", "bounce", "dvd"],
15
+ "matrix": ["matrix", "pluie", "rain", "code qui tombe", "hacker"],
16
+ "slot": ["slot", "machine a sous", "roulette", "casino", "tirage", "jackpot"],
17
+ "glitch": ["glitch", "bug", "corrompu", "casse", "parasite"],
18
+ "pulse": ["pulse", "bat", "battement", "coeur", "respire", "zoom"],
19
+ "slide": ["slide", "monte", "remonte", "entre par le bas", "générique", "credits"],
20
+ "vhs": ["vhs", "vintage", "retro", "cassette", "tracking", "analogique", "crt"],
21
+ # motifs sans texte
22
+ "starfield": ["etoile", "star", "warp", "espace", "hyperespace", "galaxie"],
23
+ "plasma": ["plasma", "lave", "psychedelique"],
24
+ "life": ["jeu de la vie", "conway", "cellule", "automate"],
25
+ "eq": ["egaliseur", "equalizer", "musique", "spectre", "barres audio", "visualiseur"],
26
+ "scope": ["oscilloscope", "oscillo", "onde", "signal", "sinusoide"],
27
+ "radar": ["radar", "sonar", "balayage", "echo"],
28
+ }
29
+
30
+
31
+ def _fold(s: str) -> str:
32
+ s = unicodedata.normalize("NFD", s.lower())
33
+ return "".join(c for c in s if unicodedata.category(c) != "Mn")
34
+
35
+
36
+ def parse(description: str) -> dict:
37
+ """Retourne {effect, text?, fps?, seconds?} déduits de la phrase."""
38
+ folded = _fold(description)
39
+ out: dict = {}
40
+
41
+ # texte entre guillemets : "..." '...' «...» “...”
42
+ m = re.search(r'["\'«“](.+?)["\'»”]', description)
43
+ if m:
44
+ out["text"] = m.group(1)
45
+
46
+ for effect, words in KEYWORDS.items():
47
+ if any(w in folded for w in words):
48
+ out["effect"] = effect
49
+ break
50
+
51
+ if any(w in folded for w in ("rapide", "vite", "fast", "quick")):
52
+ out["fps"] = 25
53
+ elif any(w in folded for w in ("lent", "lentement", "slow", "doucement")):
54
+ out["fps"] = 10
55
+
56
+ m = re.search(r"(\d+(?:[.,]\d+)?)\s*s(?:ec|econdes?)?\b", folded)
57
+ if m:
58
+ out["seconds"] = float(m.group(1).replace(",", "."))
59
+
60
+ return out
oledgif/effects.py ADDED
@@ -0,0 +1,321 @@
1
+ """Effets d'animation. Chaque effet reçoit un Ctx et retourne une liste de frames "L"."""
2
+
3
+ import math
4
+ import random
5
+ import string
6
+ from dataclasses import dataclass, field
7
+
8
+ from PIL import Image, ImageDraw
9
+
10
+ from .fonts import fit_font, load_font
11
+ from .render import canvas, char_images, text_image
12
+
13
+ ALNUM = string.digits + string.ascii_uppercase + string.ascii_lowercase
14
+
15
+ EFFECTS: dict[str, callable] = {}
16
+ DESCRIPTIONS: dict[str, str] = {}
17
+
18
+
19
+ def effect(name: str, desc: str):
20
+ def deco(fn):
21
+ EFFECTS[name] = fn
22
+ DESCRIPTIONS[name] = desc
23
+ return fn
24
+ return deco
25
+
26
+
27
+ @dataclass
28
+ class Ctx:
29
+ text: str
30
+ W: int
31
+ H: int
32
+ fps: int = 15
33
+ seconds: float = 4.0
34
+ font: object = None
35
+ font_path: str | None = None
36
+ font_size: int | None = None
37
+ charset: str = ALNUM
38
+ speed: float | None = None # px/s pour scroll
39
+ src: object = None # image source (--image) ; sinon le texte est rendu
40
+ rng: random.Random = field(default_factory=random.Random)
41
+
42
+ @property
43
+ def nframes(self) -> int:
44
+ return max(2, int(round(self.fps * self.seconds)))
45
+
46
+ def center(self, img) -> tuple[int, int]:
47
+ return (self.W - img.width) // 2, (self.H - img.height) // 2
48
+
49
+ def sprite(self):
50
+ """Ce que l'effet anime : l'image fournie, sinon le texte rendu."""
51
+ return self.src if self.src is not None else text_image(self.text, self.font)
52
+
53
+
54
+ # ---------------------------------------------------------------- scroll
55
+
56
+ @effect("scroll", "le texte/l'image défile de droite à gauche (boucle parfaite)")
57
+ def scroll(ctx: Ctx):
58
+ timg = ctx.sprite()
59
+ speed = ctx.speed or max(20.0, ctx.W / 3) # px/s
60
+ travel = ctx.W + timg.width
61
+ n = max(2, int(round(travel / speed * ctx.fps)))
62
+ y = (ctx.H - timg.height) // 2
63
+ frames = []
64
+ for i in range(n):
65
+ f = canvas(ctx.W, ctx.H)
66
+ x = ctx.W - int(round(travel * i / n))
67
+ f.paste(timg, (x, y))
68
+ frames.append(f)
69
+ return frames
70
+
71
+
72
+ # ---------------------------------------------------------------- typewriter
73
+
74
+ @effect("typewriter", "machine à écrire : les lettres apparaissent une à une, curseur clignotant")
75
+ def typewriter(ctx: Ctx):
76
+ cps = 8 # caractères/seconde
77
+ fpc = max(1, round(ctx.fps / cps))
78
+ blink = max(1, ctx.fps // 2)
79
+ hold = int(ctx.fps * 1.5)
80
+ frames = []
81
+ total = len(ctx.text) * fpc + hold
82
+ for t in range(total):
83
+ k = min(len(ctx.text), t // fpc + 1) if t < len(ctx.text) * fpc else len(ctx.text)
84
+ part = ctx.text[:k]
85
+ timg = text_image(part, ctx.font) if part.strip() else None
86
+ f = canvas(ctx.W, ctx.H)
87
+ w = timg.width if timg else int(ctx.font.getlength(part)) if part else 0
88
+ x = min(2, ctx.W - 6 - w) # reste lisible si le texte déborde
89
+ y = (ctx.H - (timg.height if timg else ctx.H // 2)) // 2
90
+ if timg:
91
+ f.paste(timg, (x, y))
92
+ if (t // blink) % 2 == 0: # curseur bloc
93
+ ch = timg.height if timg else max(8, ctx.H // 2)
94
+ cx = x + w + 2
95
+ ImageDraw.Draw(f).rectangle([cx, y, cx + max(3, ch // 2), y + ch], fill=255)
96
+ frames.append(f)
97
+ return frames
98
+
99
+
100
+ # ---------------------------------------------------------------- wave
101
+
102
+ @effect("wave", "les lettres ondulent verticalement en vague")
103
+ def wave(ctx: Ctx):
104
+ chars = char_images(ctx.text, ctx.font)
105
+ total_w = sum(adv for _, adv in chars)
106
+ x0 = (ctx.W - total_w) // 2
107
+ max_h = max((im.height for im, _ in chars if im), default=ctx.H // 2)
108
+ mid = (ctx.H - max_h) // 2
109
+ amp = max(1, min(mid, int(ctx.H * 0.18)))
110
+ frames = []
111
+ for t in range(ctx.nframes):
112
+ f = canvas(ctx.W, ctx.H)
113
+ phase = 2 * math.pi * t / ctx.nframes * max(1, round(ctx.seconds))
114
+ x = x0
115
+ for i, (im, adv) in enumerate(chars):
116
+ if im:
117
+ y = mid + int(round(amp * math.sin(phase + i * 0.7)))
118
+ f.paste(im, (x, y))
119
+ x += adv
120
+ frames.append(f)
121
+ return frames
122
+
123
+
124
+ # ---------------------------------------------------------------- blink
125
+
126
+ @effect("blink", "le texte/l'image clignote")
127
+ def blink(ctx: Ctx):
128
+ timg = ctx.sprite()
129
+ pos = ctx.center(timg)
130
+ period = max(2, ctx.fps // 2)
131
+ frames = []
132
+ for t in range(ctx.nframes):
133
+ f = canvas(ctx.W, ctx.H)
134
+ if (t // period) % 2 == 0:
135
+ f.paste(timg, pos)
136
+ frames.append(f)
137
+ return frames
138
+
139
+
140
+ # ---------------------------------------------------------------- bounce
141
+
142
+ @effect("bounce", "le texte/l'image rebondit sur les bords (façon logo DVD)")
143
+ def bounce(ctx: Ctx):
144
+ timg = ctx.sprite()
145
+ free_x, free_y = max(0, ctx.W - timg.width), max(0, ctx.H - timg.height)
146
+ vx = free_x / (ctx.fps * 1.6) if free_x else 0.0
147
+ vy = free_y / (ctx.fps * 0.9) if free_y else 0.0
148
+ vx, vy = max(vx, 0.4) if free_x else 0, max(vy, 0.4) if free_y else 0
149
+ x, y = free_x * 0.3, free_y * 0.7
150
+ frames = []
151
+ for _ in range(ctx.nframes):
152
+ f = canvas(ctx.W, ctx.H)
153
+ f.paste(timg, (int(round(x)), int(round(y))))
154
+ frames.append(f)
155
+ x += vx
156
+ y += vy
157
+ if free_x and (x < 0 or x > free_x):
158
+ vx = -vx
159
+ x = max(0.0, min(x, float(free_x)))
160
+ if free_y and (y < 0 or y > free_y):
161
+ vy = -vy
162
+ y = max(0.0, min(y, float(free_y)))
163
+ return frames
164
+
165
+
166
+ # ---------------------------------------------------------------- matrix
167
+
168
+ @effect("matrix", "pluie de caractères alphanumériques ; le texte se révèle au centre (texte optionnel)")
169
+ def matrix(ctx: Ctx):
170
+ fm = load_font(ctx.font_path, size=max(8, ctx.H // 4), screen_h=ctx.H)
171
+ cell_w = max(4, int(round(fm.getlength("0")))) if hasattr(fm, "getlength") else 6
172
+ cell_h = max(6, text_image("0", fm).height + 1)
173
+ ncols, nrows = max(1, ctx.W // cell_w), max(1, ctx.H // cell_h) + 1
174
+ rng = ctx.rng
175
+ grid = [[rng.choice(ctx.charset) for _ in range(nrows)] for _ in range(ncols)]
176
+ heads = [rng.uniform(0, nrows) for _ in range(ncols)]
177
+ speeds = [rng.uniform(0.15, 0.55) for _ in range(ncols)]
178
+ trails = [rng.randint(2, max(3, nrows)) for _ in range(ncols)]
179
+ reveal_at = int(ctx.nframes * 0.55)
180
+ timg = ctx.sprite() if (ctx.text.strip() or ctx.src is not None) else None
181
+ frames = []
182
+ for t in range(ctx.nframes):
183
+ f = canvas(ctx.W, ctx.H)
184
+ d = ImageDraw.Draw(f)
185
+ for c in range(ncols):
186
+ heads[c] = (heads[c] + speeds[c]) % (nrows + trails[c])
187
+ if rng.random() < 0.15:
188
+ grid[c][rng.randrange(nrows)] = rng.choice(ctx.charset)
189
+ for k in range(trails[c]):
190
+ r = int(heads[c]) - k
191
+ if 0 <= r < nrows:
192
+ d.text((c * cell_w, r * cell_h), grid[c][r], font=fm, fill=255)
193
+ if timg and t >= reveal_at:
194
+ x, y = ctx.center(timg)
195
+ d.rectangle([x - 3, y - 2, x + timg.width + 2, y + timg.height + 1], fill=0)
196
+ f.paste(timg, (x, y))
197
+ frames.append(f)
198
+ return frames
199
+
200
+
201
+ # ---------------------------------------------------------------- slot
202
+
203
+ @effect("slot", "machine à sous : chaque position cycle sur tout l'alphabet puis se fige")
204
+ def slot(ctx: Ctx):
205
+ cells = len(ctx.text)
206
+ size = ctx.font_size or max(8, int(ctx.H * 0.62))
207
+ font = fit_font("0" * max(1, cells), ctx.W - 4, ctx.font_path, size, ctx.H)
208
+ cell_w = int(round(font.getlength("0"))) + 1 if hasattr(font, "getlength") else 7
209
+ x0 = (ctx.W - cell_w * cells) // 2
210
+ lock = [int(ctx.nframes * 0.7 * (i + 1) / (cells + 1)) for i in range(cells)]
211
+ rng = ctx.rng
212
+ frames = []
213
+ for t in range(ctx.nframes):
214
+ f = canvas(ctx.W, ctx.H)
215
+ d = ImageDraw.Draw(f)
216
+ for i, target in enumerate(ctx.text):
217
+ ch = target if (t >= lock[i] or target == " ") else rng.choice(ctx.charset)
218
+ im = text_image(ch, font) if ch != " " else None
219
+ if im:
220
+ f.paste(im, (x0 + i * cell_w + (cell_w - im.width) // 2,
221
+ (ctx.H - im.height) // 2))
222
+ frames.append(f)
223
+ return frames
224
+
225
+
226
+ # ---------------------------------------------------------------- glitch
227
+
228
+ @effect("glitch", "texte/image corrompu : bandes décalées et bruit aléatoire")
229
+ def glitch(ctx: Ctx):
230
+ base = canvas(ctx.W, ctx.H)
231
+ timg = ctx.sprite()
232
+ base.paste(timg, ctx.center(timg))
233
+ rng = ctx.rng
234
+ frames = []
235
+ for t in range(ctx.nframes):
236
+ f = base.copy()
237
+ if t % 7 != 0: # frame propre régulière, ça respire
238
+ for _ in range(rng.randint(1, 3)):
239
+ y0 = rng.randrange(0, ctx.H - 2)
240
+ h = rng.randint(2, max(3, ctx.H // 5))
241
+ dx = rng.randint(-ctx.W // 8, ctx.W // 8)
242
+ band = f.crop((0, y0, ctx.W, min(ctx.H, y0 + h)))
243
+ ImageDraw.Draw(f).rectangle([0, y0, ctx.W, y0 + h], fill=0)
244
+ f.paste(band, (dx, y0))
245
+ px = f.load()
246
+ for _ in range(int(ctx.W * ctx.H * 0.006)):
247
+ px[rng.randrange(ctx.W), rng.randrange(ctx.H)] = 255
248
+ frames.append(f)
249
+ return frames
250
+
251
+
252
+ # ---------------------------------------------------------------- pulse
253
+
254
+ @effect("pulse", "le texte/l'image bat comme un cœur (zoom avant/arrière)")
255
+ def pulse(ctx: Ctx):
256
+ timg = ctx.sprite()
257
+ frames = []
258
+ for t in range(ctx.nframes):
259
+ s = 0.55 + 0.45 * (0.5 + 0.5 * math.sin(2 * math.pi * t / ctx.fps))
260
+ im = timg.resize((max(1, int(timg.width * s)), max(1, int(timg.height * s))),
261
+ Image.NEAREST)
262
+ f = canvas(ctx.W, ctx.H)
263
+ f.paste(im, ctx.center(im))
264
+ frames.append(f)
265
+ return frames
266
+
267
+
268
+ # ---------------------------------------------------------------- vhs
269
+
270
+ @effect("vhs", "tremblement horizontal façon cassette vidéo (défaut pour les images)")
271
+ def vhs(ctx: Ctx):
272
+ base = canvas(ctx.W, ctx.H)
273
+ timg = ctx.sprite()
274
+ base.paste(timg, ctx.center(timg))
275
+ rng = ctx.rng
276
+ n = ctx.nframes
277
+ tau = 2 * math.pi
278
+ cycles = max(1, round(ctx.seconds / 2)) # ondulations complètes par boucle
279
+ frames = []
280
+ for t in range(n):
281
+ ph = tau * t / n
282
+ f = canvas(ctx.W, ctx.H)
283
+ band_y = int(ctx.H * t / n) # bande de tracking, 1 passage par boucle
284
+ jerk = rng.choice((-2, 2)) if rng.random() < 0.12 else 0 # à-coup
285
+ for y in range(ctx.H):
286
+ off = round(1.6 * math.sin(ph * cycles + y / 6.5)
287
+ + 0.9 * math.sin(ph * 2 * cycles + y / 2.8)) + jerk
288
+ if abs(y - band_y) < 2:
289
+ off += 4 + rng.randint(0, 3)
290
+ row = base.crop((0, y, ctx.W, y + 1))
291
+ f.paste(row, (off % ctx.W, y))
292
+ f.paste(row, (off % ctx.W - ctx.W, y)) # bouclage horizontal
293
+ d = ImageDraw.Draw(f)
294
+ for _ in range(rng.randint(2, 6)): # poussière sur la bande
295
+ x = rng.randrange(ctx.W)
296
+ d.line([x, band_y, min(ctx.W - 1, x + rng.randint(1, 4)), band_y], fill=255)
297
+ frames.append(f)
298
+ return frames
299
+
300
+
301
+ # ---------------------------------------------------------------- slide
302
+
303
+ @effect("slide", "le texte/l'image entre par le bas, pause, sort par le haut")
304
+ def slide(ctx: Ctx):
305
+ timg = ctx.sprite()
306
+ x, yc = ctx.center(timg)
307
+ n = ctx.nframes
308
+ n_in, n_out = int(n * 0.25), int(n * 0.25)
309
+ frames = []
310
+ for t in range(n):
311
+ f = canvas(ctx.W, ctx.H)
312
+ if t < n_in:
313
+ y = ctx.H - int((ctx.H - yc) * (t + 1) / n_in)
314
+ elif t >= n - n_out:
315
+ k = t - (n - n_out)
316
+ y = yc - int((yc + timg.height) * (k + 1) / n_out)
317
+ else:
318
+ y = yc
319
+ f.paste(timg, (x, y))
320
+ frames.append(f)
321
+ return frames
oledgif/fonts.py ADDED
@@ -0,0 +1,34 @@
1
+ """Chargement de police. Priorité aux monospaces (rendu stable sur petit écran)."""
2
+
3
+ from PIL import ImageFont
4
+
5
+ # ImageFont.truetype cherche automatiquement dans C:\Windows\Fonts sous Windows.
6
+ CANDIDATES = ["consola.ttf", "lucon.ttf", "cour.ttf", "DejaVuSansMono.ttf", "arial.ttf"]
7
+
8
+
9
+ def load_font(path: str | None = None, size: int | None = None, screen_h: int = 40):
10
+ if size is None:
11
+ size = max(8, int(screen_h * 0.62))
12
+ if path:
13
+ return ImageFont.truetype(path, size)
14
+ for name in CANDIDATES:
15
+ try:
16
+ return ImageFont.truetype(name, size)
17
+ except OSError:
18
+ continue
19
+ return ImageFont.load_default()
20
+
21
+
22
+ def fit_font(text: str, max_w: int, path: str | None, size: int, screen_h: int):
23
+ """Réduit la taille jusqu'à ce que `text` tienne dans max_w pixels."""
24
+ font = load_font(path, size, screen_h)
25
+ while size > 7:
26
+ try:
27
+ width = font.getlength(text)
28
+ except AttributeError: # bitmap fallback sans getlength
29
+ break
30
+ if width <= max_w:
31
+ break
32
+ size -= 1
33
+ font = load_font(path, size, screen_h)
34
+ return font
oledgif/patterns.py ADDED
@@ -0,0 +1,165 @@
1
+ """Motifs animés sans texte : écrans de veille pour OLED.
2
+
3
+ Importé pour ses effets de bord (enregistrement dans EFFECTS).
4
+ """
5
+
6
+ import math
7
+
8
+ from PIL import ImageDraw
9
+
10
+ from .effects import Ctx, effect
11
+ from .render import canvas
12
+
13
+ # Effets utilisables sans texte ni image
14
+ PATTERNS = {"starfield", "plasma", "life", "eq", "scope", "radar"}
15
+
16
+ _BAYER4 = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]
17
+
18
+
19
+ @effect("starfield", "champ d'étoiles en hyperespace (sans texte)")
20
+ def starfield(ctx: Ctx):
21
+ rng = ctx.rng
22
+ n = max(24, ctx.W * ctx.H // 140)
23
+ stars = [[rng.uniform(-1, 1), rng.uniform(-1, 1), rng.uniform(0.08, 1.0)]
24
+ for _ in range(n)]
25
+ cx, cy = ctx.W / 2, ctx.H / 2
26
+ frames = []
27
+ for _ in range(ctx.nframes):
28
+ f = canvas(ctx.W, ctx.H)
29
+ px = f.load()
30
+ for s in stars:
31
+ s[2] -= 0.022
32
+ if s[2] <= 0.06:
33
+ s[0], s[1], s[2] = rng.uniform(-1, 1), rng.uniform(-1, 1), 1.0
34
+ x, y = cx + s[0] / s[2] * cx, cy + s[1] / s[2] * cy
35
+ if 0 <= x < ctx.W and 0 <= y < ctx.H:
36
+ px[int(x), int(y)] = 255
37
+ if s[2] < 0.3: # étoiles proches plus grosses
38
+ if x + 1 < ctx.W:
39
+ px[int(x) + 1, int(y)] = 255
40
+ if y + 1 < ctx.H:
41
+ px[int(x), int(y) + 1] = 255
42
+ frames.append(f)
43
+ return frames
44
+
45
+
46
+ @effect("plasma", "plasma rétro tramé Bayer, boucle parfaite (sans texte)")
47
+ def plasma(ctx: Ctx):
48
+ W, H = ctx.W, ctx.H
49
+ frames = []
50
+ for t in range(ctx.nframes):
51
+ ph = 2 * math.pi * t / ctx.nframes
52
+ f = canvas(W, H)
53
+ px = f.load()
54
+ for y in range(H):
55
+ for x in range(W):
56
+ v = (math.sin(x / 9 + 2 * ph)
57
+ + math.sin(y / 5 - 2 * ph)
58
+ + math.sin((x + y) / 11 + 4 * ph)
59
+ + math.sin(math.hypot(x - W / 2, y - H / 2) / 6 - 2 * ph)) / 4
60
+ if (v + 1) * 8 > _BAYER4[y % 4][x % 4]:
61
+ px[x, y] = 255
62
+ frames.append(f)
63
+ return frames
64
+
65
+
66
+ @effect("life", "jeu de la vie de Conway, soupe aléatoire (sans texte)")
67
+ def life(ctx: Ctx):
68
+ cell = 2
69
+ gw, gh = max(4, ctx.W // cell), max(4, ctx.H // cell)
70
+ rng = ctx.rng
71
+ grid = [[1 if rng.random() < 0.35 else 0 for _ in range(gw)] for _ in range(gh)]
72
+ frames = []
73
+ for _ in range(ctx.nframes):
74
+ f = canvas(ctx.W, ctx.H)
75
+ d = ImageDraw.Draw(f)
76
+ for r in range(gh):
77
+ for c in range(gw):
78
+ if grid[r][c]:
79
+ d.rectangle([c * cell, r * cell, c * cell + cell - 1,
80
+ r * cell + cell - 1], fill=255)
81
+ frames.append(f)
82
+ nxt = [[0] * gw for _ in range(gh)]
83
+ alive = 0
84
+ for r in range(gh):
85
+ for c in range(gw):
86
+ nb = sum(grid[(r + dr) % gh][(c + dc) % gw]
87
+ for dr in (-1, 0, 1) for dc in (-1, 0, 1)
88
+ if (dr, dc) != (0, 0))
89
+ nxt[r][c] = 1 if (nb == 3 or (grid[r][c] and nb == 2)) else 0
90
+ alive += nxt[r][c]
91
+ grid = nxt
92
+ if alive < gw * gh * 0.03: # la soupe est morte : on ressème
93
+ grid = [[1 if rng.random() < 0.35 else 0 for _ in range(gw)]
94
+ for _ in range(gh)]
95
+ return frames
96
+
97
+
98
+ @effect("eq", "égaliseur audio : barres et pics qui retombent (sans texte)")
99
+ def eq(ctx: Ctx):
100
+ bar_w, gap = 4, 2
101
+ nb = max(3, (ctx.W + gap) // (bar_w + gap))
102
+ rng = ctx.rng
103
+ freqs = [(rng.randint(1, 3), rng.randint(2, 5), rng.uniform(0, math.tau))
104
+ for _ in range(nb)]
105
+ peaks = [0.0] * nb
106
+ frames = []
107
+ for t in range(ctx.nframes):
108
+ ph = 2 * math.pi * t / ctx.nframes
109
+ f = canvas(ctx.W, ctx.H)
110
+ d = ImageDraw.Draw(f)
111
+ for i, (f1, f2, p0) in enumerate(freqs):
112
+ v = abs(0.65 * math.sin(f1 * ph + p0) + 0.35 * math.sin(f2 * ph + 2 * p0))
113
+ h = max(1, int(v * (ctx.H - 3)))
114
+ peaks[i] = max(peaks[i] - ctx.H / (ctx.fps * 1.2), h + 2)
115
+ x = i * (bar_w + gap)
116
+ d.rectangle([x, ctx.H - h, x + bar_w - 1, ctx.H - 1], fill=255)
117
+ py = ctx.H - 1 - int(min(peaks[i], ctx.H - 1))
118
+ d.line([x, py, x + bar_w - 1, py], fill=255)
119
+ frames.append(f)
120
+ return frames
121
+
122
+
123
+ @effect("scope", "oscilloscope : onde sinusoïdale modulée (sans texte)")
124
+ def scope(ctx: Ctx):
125
+ cy = ctx.H / 2
126
+ amp = ctx.H * 0.42
127
+ frames = []
128
+ for t in range(ctx.nframes):
129
+ ph = 2 * math.pi * t / ctx.nframes
130
+ f = canvas(ctx.W, ctx.H)
131
+ d = ImageDraw.Draw(f)
132
+ for gx in range(0, ctx.W, 8): # graduations discrètes
133
+ f.putpixel((gx, int(cy)), 255)
134
+ pts = []
135
+ for x in range(ctx.W):
136
+ env = 0.5 + 0.5 * math.sin(2 * math.pi * x / ctx.W - 2 * ph)
137
+ y = cy + amp * env * math.sin(2 * math.pi * 3 * x / ctx.W + 4 * ph)
138
+ pts.append((x, y))
139
+ d.line(pts, fill=255)
140
+ frames.append(f)
141
+ return frames
142
+
143
+
144
+ @effect("radar", "balayage radar avec échos qui s'effacent (sans texte)")
145
+ def radar(ctx: Ctx):
146
+ cx, cy = ctx.W // 2, ctx.H // 2
147
+ R = min(cx, cy) - 1
148
+ rng = ctx.rng
149
+ blips = [(rng.uniform(0, math.tau), rng.uniform(0.3, 0.95)) for _ in range(6)]
150
+ rotations = max(1, round(ctx.seconds / 2))
151
+ frames = []
152
+ for t in range(ctx.nframes):
153
+ sweep = (2 * math.pi * rotations * t / ctx.nframes) % math.tau
154
+ f = canvas(ctx.W, ctx.H)
155
+ d = ImageDraw.Draw(f)
156
+ d.ellipse([cx - R, cy - R, cx + R, cy + R], outline=255)
157
+ d.line([cx, cy,
158
+ cx + R * math.cos(sweep), cy + R * math.sin(sweep)], fill=255)
159
+ for ang, dist in blips:
160
+ age = (sweep - ang) % math.tau
161
+ if age < 1.4: # l'écho reste visible ~1/4 de tour
162
+ bx, by = cx + R * dist * math.cos(ang), cy + R * dist * math.sin(ang)
163
+ d.rectangle([bx - 1, by - 1, bx + 1, by + 1], fill=255)
164
+ frames.append(f)
165
+ return frames
oledgif/presets.py ADDED
@@ -0,0 +1,29 @@
1
+ """Presets d'écrans OLED répandus sur le marché."""
2
+
3
+ PRESETS = {
4
+ # SteelSeries (GameSense / SteelSeries GG)
5
+ "apex": (128, 40, "SteelSeries Apex 5 / 7 / Pro — OLED clavier"),
6
+ "rival": (128, 36, "SteelSeries Rival 700 / 710 — OLED souris"),
7
+ # Modules OLED I2C/SPI courants (Arduino, Raspberry Pi, macropads QMK...)
8
+ "oled-128x64": (128, 64, "SSD1306 / SH1106 / SSD1309 128x64"),
9
+ "oled-128x32": (128, 32, "SSD1306 128x32"),
10
+ "oled-96x16": (96, 16, "SSD1306 96x16"),
11
+ "oled-256x64": (256, 64, "SSD1322 256x64"),
12
+ }
13
+
14
+ DEFAULT_PRESET = "apex"
15
+
16
+
17
+ def resolve_size(preset: str | None, size: str | None) -> tuple[int, int]:
18
+ """--size WxH prime sur --preset ; sinon preset (défaut: apex)."""
19
+ if size:
20
+ try:
21
+ w, h = size.lower().split("x")
22
+ return int(w), int(h)
23
+ except ValueError:
24
+ raise SystemExit(f"--size invalide: {size!r} (attendu: LARGEURxHAUTEUR, ex. 128x40)")
25
+ name = preset or DEFAULT_PRESET
26
+ if name not in PRESETS:
27
+ raise SystemExit(f"Preset inconnu: {name!r}. Presets: {', '.join(PRESETS)}")
28
+ w, h, _ = PRESETS[name]
29
+ return w, h
oledgif/render.py ADDED
@@ -0,0 +1,209 @@
1
+ """Rendu bas niveau : texte/image -> bitmap, binarisation 1 bit, écriture GIF."""
2
+
3
+ from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageSequence
4
+
5
+ THRESHOLD = 128
6
+
7
+
8
+ def canvas(w: int, h: int) -> Image.Image:
9
+ return Image.new("L", (w, h), 0)
10
+
11
+
12
+ def text_image(text: str, font) -> Image.Image:
13
+ """Rend `text` (blanc sur noir) dans une image ajustée au plus près."""
14
+ probe = ImageDraw.Draw(Image.new("L", (1, 1)))
15
+ bbox = probe.textbbox((0, 0), text, font=font)
16
+ w = max(1, bbox[2] - bbox[0])
17
+ h = max(1, bbox[3] - bbox[1])
18
+ img = Image.new("L", (w, h), 0)
19
+ ImageDraw.Draw(img).text((-bbox[0], -bbox[1]), text, font=font, fill=255)
20
+ return img
21
+
22
+
23
+ def char_images(text: str, font):
24
+ """Liste (image, avance_x) par caractère — pour les effets par lettre."""
25
+ out = []
26
+ for ch in text:
27
+ try:
28
+ adv = font.getlength(ch)
29
+ except AttributeError:
30
+ adv = text_image(ch, font).width + 1
31
+ out.append((None if ch == " " else text_image(ch, font), int(round(adv))))
32
+ return out
33
+
34
+
35
+ def _fit(im: Image.Image, max_w: int, max_h: int, fit: str) -> Image.Image:
36
+ if fit == "cover":
37
+ return ImageOps.fit(im, (max_w, max_h), Image.LANCZOS)
38
+ scale = min(max_w / im.width, max_h / im.height)
39
+ return im.resize((max(1, round(im.width * scale)), max(1, round(im.height * scale))),
40
+ Image.LANCZOS)
41
+
42
+
43
+ def _despeckle(im: Image.Image) -> Image.Image:
44
+ """Éteint tout pixel 0/255 dont aucun des 8 voisins n'a sa couleur.
45
+
46
+ Élimine le « poivre et sel » (reflets, sources de lumière ponctuelles)
47
+ sans toucher à la trame Floyd-Steinberg, dont les pixels ont toujours
48
+ des voisins diagonaux de même couleur.
49
+ """
50
+ w, h = im.size
51
+ px = im.load()
52
+ out = im.copy()
53
+ po = out.load()
54
+ for y in range(h):
55
+ for x in range(w):
56
+ v = px[x, y]
57
+ alone = True
58
+ for dy in (-1, 0, 1):
59
+ for dx in (-1, 0, 1):
60
+ if dx == 0 and dy == 0:
61
+ continue
62
+ nx, ny = x + dx, y + dy
63
+ if 0 <= nx < w and 0 <= ny < h and px[nx, ny] == v:
64
+ alone = False
65
+ break
66
+ if not alone:
67
+ break
68
+ if alone:
69
+ po[x, y] = 255 - v
70
+ return out
71
+
72
+
73
+ def _otsu(hist) -> int | None:
74
+ """Seuil d'Otsu (variance inter-classes maximale) sur un histogramme 256 bins."""
75
+ total = sum(hist)
76
+ if total == 0:
77
+ return None
78
+ s_all = sum(i * h for i, h in enumerate(hist))
79
+ w0 = s0 = 0
80
+ best, thr = 0.0, None
81
+ for t in range(256):
82
+ w0 += hist[t]
83
+ if w0 == 0:
84
+ continue
85
+ w1 = total - w0
86
+ if w1 == 0:
87
+ break
88
+ s0 += t * hist[t]
89
+ m0, m1 = s0 / w0, (s_all - s0) / w1
90
+ var = w0 * w1 * (m0 - m1) ** 2
91
+ if var > best:
92
+ best, thr = var, t
93
+ return thr
94
+
95
+
96
+ def _prepare(im: Image.Image, max_w: int, max_h: int,
97
+ style: str = "photo", fit: str = "contain",
98
+ denoise: bool = True) -> Image.Image:
99
+ """Niveaux de gris + redimensionnement, puis conversion 1 bit selon `style`.
100
+
101
+ photo : auto-contraste + netteté + trame Floyd-Steinberg (photos)
102
+ solid : seuillage net à l'écriture (logos, dessins au trait)
103
+ comic : solid + les formes noyées dans le noir retracées en contour blanc
104
+ edges : détection de contours -> traits blancs sur noir (photos très petites)
105
+ denoise: filtre médian avant conversion + suppression des pixels isolés après
106
+ """
107
+ im = ImageOps.exif_transpose(im).convert("L")
108
+ if style == "edges":
109
+ # contours calculés en 2x puis épaissis, sinon ils disparaissent à la réduction
110
+ big = _fit(im, max_w * 2, max_h * 2, fit)
111
+ big = ImageOps.autocontrast(big, cutoff=1)
112
+ if denoise:
113
+ big = big.filter(ImageFilter.MedianFilter(3))
114
+ big = big.filter(ImageFilter.GaussianBlur(1)).filter(ImageFilter.FIND_EDGES)
115
+ ImageDraw.Draw(big).rectangle([0, 0, big.width - 1, big.height - 1],
116
+ outline=0, width=2) # cadre = artefact du bord
117
+ big = ImageOps.autocontrast(big).filter(ImageFilter.MaxFilter(3))
118
+ small = big.resize((max(1, big.width // 2), max(1, big.height // 2)), Image.LANCZOS)
119
+ # seuil adaptatif : on garde ~10 % des pixels (les contours les plus forts)
120
+ hist, acc, thr = small.histogram(), 0, 255
121
+ budget = small.width * small.height * 0.10
122
+ for lvl in range(255, -1, -1):
123
+ acc += hist[lvl]
124
+ if acc >= budget:
125
+ thr = lvl
126
+ break
127
+ thr = max(24, thr)
128
+ small = small.point(lambda p: 255 if p >= thr else 0)
129
+ return _despeckle(small) if denoise else small
130
+ im = _fit(im, max_w, max_h, fit)
131
+ im = ImageOps.autocontrast(im, cutoff=1)
132
+ if style == "comic":
133
+ if denoise:
134
+ im = im.filter(ImageFilter.MedianFilter(3))
135
+ solid = im.point(lambda p: 255 if p >= THRESHOLD else 0)
136
+ dark = ImageChops.invert(solid) # blanc = zones noires du seuillage
137
+ hist = im.histogram(dark.convert("1"))
138
+ thr = _otsu(hist)
139
+ total = sum(hist)
140
+ if thr is None or total == 0:
141
+ return solid
142
+ lo = sum(hist[:thr + 1])
143
+ if min(lo, total - lo) / total < 0.05: # zone noire quasi unie : rien à tracer
144
+ return solid
145
+ # frontière entre les deux populations sombres (ex. ciel / silhouette),
146
+ # tenue à 1 px des zones blanches pour ne pas doubler leurs bords
147
+ mid = ImageChops.multiply(im.point(lambda p, t=thr: 255 if p > t else 0), dark)
148
+ contour = ImageChops.difference(mid, mid.filter(ImageFilter.MinFilter(3)))
149
+ contour = ImageChops.multiply(contour, dark.filter(ImageFilter.MinFilter(3)))
150
+ out = ImageChops.lighter(solid, contour)
151
+ return _despeckle(out) if denoise else out
152
+ if style == "photo":
153
+ if denoise: # pas en solid : le médian arrondirait les logos nets
154
+ im = im.filter(ImageFilter.MedianFilter(3))
155
+ im = im.filter(ImageFilter.UnsharpMask(radius=1.2, percent=160, threshold=2))
156
+ im = im.convert("1", dither=Image.FLOYDSTEINBERG).convert("L")
157
+ if denoise:
158
+ im = _despeckle(im)
159
+ return im
160
+
161
+
162
+ def load_source(path: str, max_w: int, max_h: int, style: str = "photo",
163
+ fit: str = "contain", denoise: bool = True) -> Image.Image:
164
+ """Charge une image et la prépare comme 'sprite' 1 bit pour les effets."""
165
+ return _prepare(Image.open(path), max_w, max_h, style, fit, denoise)
166
+
167
+
168
+ def convert_animation(path: str, w: int, h: int, style: str = "photo",
169
+ fit: str = "contain", denoise: bool = True):
170
+ """Convertit un GIF animé existant : frames centrées + durées d'origine."""
171
+ im = Image.open(path)
172
+ frames, durations = [], []
173
+ for raw in ImageSequence.Iterator(im):
174
+ sprite = _prepare(raw.convert("L"), w, h, style, fit, denoise)
175
+ f = canvas(w, h)
176
+ f.paste(sprite, ((w - sprite.width) // 2, (h - sprite.height) // 2))
177
+ frames.append(f)
178
+ durations.append(max(20, int(raw.info.get("duration", 100))))
179
+ return frames, durations
180
+
181
+
182
+ def binarize(frame: Image.Image, invert: bool = False) -> Image.Image:
183
+ if invert:
184
+ return frame.point(lambda p: 0 if p >= THRESHOLD else 255).convert("1")
185
+ return frame.point(lambda p: 255 if p >= THRESHOLD else 0).convert("1")
186
+
187
+
188
+ def save_gif(frames, path: str, fps: int, invert: bool = False, scale: int = 1,
189
+ loop: int = 0, durations=None):
190
+ """Binarise, agrandit (nearest) si demandé, et écrit le GIF bouclé.
191
+
192
+ `durations` (liste de ms par frame) prime sur `fps` — utile en conversion.
193
+ """
194
+ out = []
195
+ for f in frames:
196
+ b = binarize(f, invert)
197
+ if scale > 1:
198
+ b = b.resize((b.width * scale, b.height * scale), Image.NEAREST)
199
+ out.append(b.convert("P"))
200
+ duration = durations if durations else max(20, int(round(1000 / fps)))
201
+ out[0].save(
202
+ path,
203
+ save_all=True,
204
+ append_images=out[1:],
205
+ duration=duration,
206
+ loop=loop,
207
+ optimize=True,
208
+ )
209
+ return len(out), (duration if isinstance(duration, int) else duration[0])
@@ -0,0 +1,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: oledgifstudio
3
+ Version: 0.1.0
4
+ Summary: Générateur de GIFs animés 1 bit pour écrans OLED (SteelSeries, SSD1306, ...)
5
+ Author-email: MaticeMrll <marill.matice@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 MaticeMrll
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/MaticeMrll/oled-gif-studio
29
+ Project-URL: Repository, https://github.com/MaticeMrll/oled-gif-studio
30
+ Project-URL: Issues, https://github.com/MaticeMrll/oled-gif-studio/issues
31
+ Keywords: oled,gif,steelseries,ssd1306,keyboard,pillow
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Classifier: Topic :: Multimedia :: Graphics
36
+ Classifier: Environment :: Console
37
+ Requires-Python: >=3.10
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE
40
+ Requires-Dist: Pillow>=9.0
41
+ Dynamic: license-file
42
+
43
+ # oled-gif-studio
44
+
45
+ <p align="center">
46
+ <img src="docs/hero.gif" width="384" alt="OLED GIF STUDIO qui défile sur un écran 128x40">
47
+ </p>
48
+
49
+ Générateur de GIFs animés **1 bit** pour les petits écrans OLED : claviers/souris
50
+ SteelSeries, modules SSD1306/SH1106 (Arduino, Raspberry Pi, macropads QMK)...
51
+
52
+ Pas de modèle IA, pas d'API payante : tout est **procédural** (Python + Pillow),
53
+ donc instantané, gratuit et pixel-perfect. Un mode « description en langage
54
+ naturel » (français ou anglais) choisit l'effet et les paramètres pour toi.
55
+
56
+ > Tous les aperçus de ce README sont agrandis ×3 — la taille réelle des GIFs
57
+ > est celle de l'écran cible (128×40 par défaut).
58
+
59
+ ## Installation
60
+
61
+ Aucune, si Python (≥ 3.10) + Pillow sont déjà installés. Sinon :
62
+
63
+ ```
64
+ pip install Pillow
65
+ ```
66
+
67
+ Optionnel, pour avoir la commande `oledgif` partout :
68
+
69
+ ```
70
+ pip install -e .
71
+ ```
72
+
73
+ ## Usage rapide
74
+
75
+ ```powershell
76
+ # Depuis le dossier du projet :
77
+ python -m oledgif "HELLO WORLD" # effet auto → hello.gif
78
+ python -m oledgif "GG" -e slot -o gg.gif # machine à sous
79
+ python -m oledgif "PWNED" -e glitch -p rival # pour l'OLED d'une souris
80
+ python -m oledgif "42" -e matrix --size 128x64 --fps 20 # taille custom
81
+
82
+ # En langage naturel :
83
+ python -m oledgif -d "le texte 'BONJOUR' défile lentement"
84
+ python -m oledgif -d "'GAME OVER' qui clignote vite pendant 3s"
85
+ python -m oledgif -d "un radar qui balaie l'écran"
86
+
87
+ # À partir d'une image :
88
+ python -m oledgif -i photo.jpg --fit cover # photo plein écran, effet vhs
89
+ python -m oledgif -i comic.jpg --fit cover --style comic # illustration/BD
90
+ python -m oledgif -i logo.png -e bounce # le logo rebondit
91
+ python -m oledgif -i meme.gif # GIF animé converti tel quel
92
+
93
+ # Motifs sans texte (écrans de veille) :
94
+ python -m oledgif -e starfield
95
+ python -m oledgif -e plasma --seconds 6
96
+
97
+ # Un exemple de chaque effet dans ./samples :
98
+ python -m oledgif --demo
99
+ ```
100
+
101
+ ## Effets texte / image (`--list-effects`)
102
+
103
+ <table>
104
+ <tr>
105
+ <td align="center"><img src="docs/scroll.gif" alt="scroll"><br><code>scroll</code> — défilement en boucle parfaite</td>
106
+ <td align="center"><img src="docs/typewriter.gif" alt="typewriter"><br><code>typewriter</code> — machine à écrire avec curseur</td>
107
+ </tr>
108
+ <tr>
109
+ <td align="center"><img src="docs/wave.gif" alt="wave"><br><code>wave</code> — lettres en vague</td>
110
+ <td align="center"><img src="docs/blink.gif" alt="blink"><br><code>blink</code> — clignotement</td>
111
+ </tr>
112
+ <tr>
113
+ <td align="center"><img src="docs/bounce.gif" alt="bounce"><br><code>bounce</code> — rebond façon logo DVD</td>
114
+ <td align="center"><img src="docs/matrix.gif" alt="matrix"><br><code>matrix</code> — pluie de caractères qui révèle le texte</td>
115
+ </tr>
116
+ <tr>
117
+ <td align="center"><img src="docs/slot.gif" alt="slot"><br><code>slot</code> — chaque lettre cycle puis se fige</td>
118
+ <td align="center"><img src="docs/glitch.gif" alt="glitch"><br><code>glitch</code> — bandes décalées + bruit</td>
119
+ </tr>
120
+ <tr>
121
+ <td align="center"><img src="docs/pulse.gif" alt="pulse"><br><code>pulse</code> — battement de cœur</td>
122
+ <td align="center"><img src="docs/vhs.gif" alt="vhs"><br><code>vhs</code> — tracking façon cassette vidéo</td>
123
+ </tr>
124
+ <tr>
125
+ <td align="center"><img src="docs/slide.gif" alt="slide"><br><code>slide</code> — générique bas → haut</td>
126
+ <td></td>
127
+ </tr>
128
+ </table>
129
+
130
+ `typewriter`, `wave`, `matrix` et `slot` sont réservés au texte ; les autres
131
+ acceptent aussi une image (`--image`).
132
+
133
+ `--effect auto` (défaut) : `scroll` si le texte est trop large pour l'écran,
134
+ sinon `wave` ; `vhs` pour une image ; conversion directe pour un GIF animé.
135
+
136
+ ## Motifs sans texte (écrans de veille)
137
+
138
+ <table>
139
+ <tr>
140
+ <td align="center"><img src="docs/starfield.gif" alt="starfield"><br><code>starfield</code> — hyperespace</td>
141
+ <td align="center"><img src="docs/plasma.gif" alt="plasma"><br><code>plasma</code> — plasma rétro tramé</td>
142
+ </tr>
143
+ <tr>
144
+ <td align="center"><img src="docs/life.gif" alt="life"><br><code>life</code> — jeu de la vie de Conway</td>
145
+ <td align="center"><img src="docs/eq.gif" alt="eq"><br><code>eq</code> — égaliseur audio</td>
146
+ </tr>
147
+ <tr>
148
+ <td align="center"><img src="docs/scope.gif" alt="scope"><br><code>scope</code> — oscilloscope</td>
149
+ <td align="center"><img src="docs/radar.gif" alt="radar"><br><code>radar</code> — balayage avec échos</td>
150
+ </tr>
151
+ </table>
152
+
153
+ ## Images : les 4 styles de rendu
154
+
155
+ Convertir une image couleur en 1 bit sur 5120 pixels est un exercice de
156
+ sacrifice — le bon `--style` dépend de la source. Démonstration sur la même
157
+ scène (lune, silhouettes sur ciel en dégradé, lumières de ville) :
158
+
159
+ | Rendu | Style |
160
+ |---|---|
161
+ | <img src="docs/style_source.png" alt="source" width="288"> | **Source** (niveaux de gris) |
162
+ | <img src="docs/style_photo.png" alt="photo" width="288"> | `--style photo` (défaut) — auto-contraste + netteté + trame Floyd-Steinberg. Le bon choix pour les **photos réelles** : la trame simule les niveaux de gris. Sur une illustration, elle devient du bruit. |
163
+ | <img src="docs/style_solid.png" alt="solid" width="288"> | `--style solid` — seuillage net (alias `--no-dither`). Parfait pour les **logos** et dessins au trait… mais tout ce qui est sombre-sur-sombre disparaît dans le noir. |
164
+ | <img src="docs/style_comic.png" alt="comic" width="288"> | `--style comic` — comme `solid`, mais un 2ᵉ seuil automatique (Otsu) sépare les tons sombres et retrace en **contour blanc** les formes noyées dans le noir. Idéal pour les **illustrations/BD**. |
165
+ | <img src="docs/style_edges.png" alt="edges" width="288"> | `--style edges` — détection de contours, traits blancs sur fond noir. Look néon/filaire, souvent le plus lisible pour un **paysage ou un visage**. |
166
+
167
+ Autres options d'image :
168
+
169
+ - `--fit cover` — l'image remplit tout l'écran (recadrée) au lieu d'être
170
+ réduite à un timbre-poste au milieu. Recommandé pour les photos paysage.
171
+ - **Dé-bruitage** (actif par défaut) : un filtre médian + une suppression des
172
+ pixels isolés éliminent le « poivre et sel » (reflets, lampadaires, étoiles).
173
+ `--no-denoise` le désactive si tu veux justement garder ces points.
174
+ - Un **GIF animé** en entrée est converti frame par frame en conservant les
175
+ durées d'origine (avec `--effect auto`).
176
+
177
+ ## Langage naturel (`--describe`)
178
+
179
+ ```powershell
180
+ python -m oledgif -d "le texte 'BRB' qui rebondit pendant 5 secondes"
181
+ python -m oledgif -d "'REC' en mode vhs vintage"
182
+ python -m oledgif -d "a fast blinking 'GO' for 3 seconds"
183
+ ```
184
+
185
+ Le parseur (FR/EN, insensible aux accents) reconnaît l'effet par mots-clés
186
+ (« défile », « clignote », « tape », « radar », « vintage »…), le texte entre
187
+ guillemets, la durée (« pendant 3s ») et la vitesse (« lentement », « vite »).
188
+
189
+ ## Écrans préconfigurés (`--list-presets`)
190
+
191
+ | Preset | Taille | Matériel |
192
+ |---------------|---------|----------|
193
+ | `apex` (défaut) | 128×40 | SteelSeries Apex 5 / 7 / Pro (OLED clavier) |
194
+ | `rival` | 128×36 | SteelSeries Rival 700 / 710 (OLED souris) |
195
+ | `oled-128x64` | 128×64 | SSD1306 / SH1106 / SSD1309 |
196
+ | `oled-128x32` | 128×32 | SSD1306 |
197
+ | `oled-96x16` | 96×16 | SSD1306 |
198
+ | `oled-256x64` | 256×64 | SSD1322 |
199
+
200
+ N'importe quelle autre taille : `--size LARGEURxHAUTEUR`.
201
+
202
+ ## Options utiles
203
+
204
+ - `--charset alnum|digits|upper|letters|ascii` ou une chaîne littérale — le
205
+ jeu de caractères utilisé par `matrix` et `slot` (défaut : les 62
206
+ alphanumériques `0-9A-Za-z`).
207
+ - `--font chemin.ttf --font-size N` — police custom (défaut : Consolas,
208
+ taille ajustée à l'écran).
209
+ - `--invert` — noir sur blanc.
210
+ - `--scale 4` — GIF agrandi ×4 (pour prévisualiser confortablement).
211
+ - `--seed 42` — rendu reproductible pour les effets aléatoires.
212
+ - `--fps`, `--seconds`, `--speed` (px/s pour scroll).
213
+
214
+ ## Envoyer le GIF sur l'écran SteelSeries
215
+
216
+ Deux options :
217
+
218
+ 1. **SteelSeries GG** : dans les réglages de ton clavier (section OLED),
219
+ tu peux importer une image/GIF 128×40 — les GIFs générés ici sont au bon
220
+ format (1 bit, taille exacte).
221
+ 2. **GameSense API** (programmatique) : comme le fait
222
+ [SteelseriesAnimGif](https://github.com/bolner/SteelseriesAnimGif), on peut
223
+ streamer les frames vers l'écran via l'API locale de SteelSeries GG.
224
+ Les GIFs produits ici sont directement exploitables frame par frame.
225
+
226
+ ## Structure du projet
227
+
228
+ ```
229
+ oledgif/
230
+ cli.py # ligne de commande + make_gif()
231
+ effects.py # les 11 effets texte/image (registre @effect)
232
+ patterns.py # les 6 motifs sans texte
233
+ render.py # préparation d'images, binarisation, écriture GIF
234
+ describe.py # parseur langage naturel FR/EN
235
+ presets.py # tailles d'écrans du marché
236
+ fonts.py # chargement de police
237
+ samples/ # un GIF d'exemple par effet (taille réelle, --demo)
238
+ docs/ # illustrations du README (agrandies ×3)
239
+ ```
240
+ ---
241
+ ## Mon choix
242
+
243
+ <img src="docs/keyboard.jpg" alt="source" width="288">
244
+
245
+ Ninja Turtles !
@@ -0,0 +1,15 @@
1
+ oledgif/__init__.py,sha256=CfdGypEDKbwS5Gajw3xULiynQ5pKZSM1JzOtMF1jRNE,117
2
+ oledgif/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ oledgif/cli.py,sha256=m6Td-wPLArm7Kheph-oil0fEwiSOmheV4RsxZP4XKYA,8178
4
+ oledgif/describe.py,sha256=xwxe5l2OYCb5t-BlhHJC598i4y1AQ-4AyntJE9_hYTI,2521
5
+ oledgif/effects.py,sha256=r-x7ZtU6hJZth3H_eWCxWaT6Azhzv6G_3HzWPEiz1GY,11688
6
+ oledgif/fonts.py,sha256=Bnc5Y9IPYF2mZOp4UATuCeLr2kujuWwMj6nokoQ1mnU,1153
7
+ oledgif/patterns.py,sha256=T5T8jF_TU4jvZQdiR-dSFFUtZ1Y9w4shgVOWJdT8458,6072
8
+ oledgif/presets.py,sha256=YuVCfLX1kbJO7wG2viomByPkKHKUPlG2zX-XG3QbhwA,1161
9
+ oledgif/render.py,sha256=XLgZWQAabtJLIbrJu3XJT6JczhuenLIoCbVUcdAtvUE,8366
10
+ oledgifstudio-0.1.0.dist-info/licenses/LICENSE,sha256=uWzjux6kyECC8L0SRzSZPFIprrqBS5jHM7An9oyNQiw,1067
11
+ oledgifstudio-0.1.0.dist-info/METADATA,sha256=sMZBjxqQpTG-WH7mqp0uKOYsdOPZkhVJk_VB9fq2bFo,11104
12
+ oledgifstudio-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ oledgifstudio-0.1.0.dist-info/entry_points.txt,sha256=XKSU_9joU04QuaLpsI-St3pbiIWIYA6s8lWOEErxN80,45
14
+ oledgifstudio-0.1.0.dist-info/top_level.txt,sha256=17bnjJbfg_S4lecEKvGv3669PfigTYQxQntr4FXQcAQ,8
15
+ oledgifstudio-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ oledgif = oledgif.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MaticeMrll
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ oledgif