-
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathLEDtv.py
More file actions
3352 lines (2999 loc) · 112 KB
/
Copy pathLEDtv.py
File metadata and controls
3352 lines (2999 loc) · 112 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
#------------------------------------------------------------------------------
# LEDTV — LED matrix television effects
#
# Default channel-surf loop (effect=channels):
# 1. "LEDTV" letters drop from the sky (Skyfall-style intro)
# 2. Analog static for 3 seconds
# 3. Channel flash 2–5 times (1 second each, CHn up/down)
# 4. Random local video for 30 seconds
# 5. Channel up or down; repeat from (3) until duration ends
#
# Other effects:
# white_noise — classic analog static with green CHn overlay
# color_bars — SMPTE-style off-air color bars with green CHn
# youtube — stream a YouTube URL or play a local video file
#
# Local videos live in videos/ (gitignored). Drop .mp4/.webm/etc. there.
#
# Run: sudo python3 LEDtv.py
# sudo python3 LEDtv.py --duration 5
# sudo python3 LEDtv.py --youtube 'https://www.youtube.com/watch?v=...'
# Or: launch_ledtv via LEDcommander / LEDpanel / Twitch ?tv
#------------------------------------------------------------------------------
from __future__ import print_function
import copy
import glob
import os
import random
import re
import shutil
import subprocess
import sys
import threading
import time
import numpy as np
from PIL import Image, ImageSequence
import LEDarcade as LED
LED.Initialize()
# --- display ---
WIDTH = LED.HatWidth
HEIGHT = LED.HatHeight
_HERE = os.path.dirname(os.path.abspath(__file__))
GIF_DIR = os.path.join(_HERE, "images")
VIDEO_DIR = os.path.join(_HERE, "videos")
# Pre-baked panel frames (same filter as live scale). Originals in videos/ untouched.
PANEL_VIDEO_DIR = os.path.join(VIDEO_DIR, "panel")
PREFER_PANEL_BAKE = True # play videos/panel/<name> when present
GIF_FRAME_SLEEP = 0.06 # default delay between GIF frames
GIF_LOOPS_EACH = 2 # full plays of one GIF before picking another
GIF_BETWEEN_BARS_SEC = 0.35 # brief black between channel items
VIDEO_EXTS = (".mp4", ".webm", ".mkv", ".mov", ".avi", ".m4v")
# --- session defaults ---
DEFAULT_DURATION_MIN = 5.0 # commander / Twitch / LaunchLEDtv default
# Standalone CLI (python LEDtv.py) runs until killed unless --duration is set
STANDALONE_DURATION_MIN = 100000.0
# --- channel-surf timing ---
STATIC_BOOT_SEC = 3.0 # static at start (after title)
CHANNEL_FLASH_SEC = 2.0 # momentary video per channel change (if previews on)
CHANNEL_FLASH_MIN = 2
CHANNEL_FLASH_MAX = 5
# 2s channel-preview clips (off by default; surf = sequential landed channels)
CHANNEL_PREVIEWS_ENABLED = False
# How long each landed channel stays on after boot warmup (title + static)
CHANNEL_DWELL_MIN_SEC = 10.0
CHANNEL_DWELL_MAX_SEC = 10.0
CHANNEL_DWELL_SEC = 10.0 # fixed dwell after initial warmup
VIDEO_PLAY_SEC = 10.0 # legacy alias; prefer channel_dwell_sec()
CHANNEL_BUG_SHOW_SEC = 3.0 # CHn visible this long after landing; hide until next change
# Black interstitial between channels (0 = seamless cut; CHn is on the content)
CHANNEL_CHANGE_FLASH_SEC = 0.0
# How long to wait for ffmpeg to die after kill (keep short to avoid pause)
FFMPEG_KILL_WAIT_SEC = 0.25
# --- youtube / video ---
# Full 30 fps — hardware can keep up; panel bakes should match this rate
VIDEO_FPS = 30
VIDEO_FRAME_BYTES = WIDTH * HEIGHT * 3
# Prefer a recent yt-dlp binary if present
_YT_DLP_CANDIDATES = (
"/usr/local/bin/yt-dlp",
"/usr/bin/yt-dlp",
shutil.which("yt-dlp") or "",
)
# Common locations — subprocess PATH under sudo/LEDcommander is often empty
_FFMPEG_CANDIDATES = (
"/usr/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"/bin/ffmpeg",
shutil.which("ffmpeg") or "",
)
_FFPROBE_CANDIDATES = (
"/usr/bin/ffprobe",
"/usr/local/bin/ffprobe",
"/bin/ffprobe",
shutil.which("ffprobe") or "",
)
def _resolve_tool(candidates, name="tool"):
"""Return first existing executable path from candidates, or None."""
for path in candidates:
if path and os.path.isfile(path) and os.access(path, os.X_OK):
return path
# Last resort: PATH lookup at call time (env may differ from import)
found = shutil.which(name)
if found and os.path.isfile(found):
return found
return None
def get_ffmpeg():
"""ffmpeg path (resolved at call time so sudo/subprocess PATH still works)."""
return _resolve_tool(_FFMPEG_CANDIDATES, "ffmpeg")
def get_ffprobe():
return _resolve_tool(_FFPROBE_CANDIDATES, "ffprobe")
# Back-compat module-level names (may be None until tools exist)
_FFMPEG = get_ffmpeg() or "/usr/bin/ffmpeg"
_FFPROBE = get_ffprobe() or "/usr/bin/ffprobe"
# Cache of probed durations (path → seconds); avoids re-probing every surf
_DURATION_CACHE = {}
# --- white noise ---
NOISE_FPS = 30
NOISE_MIN = 8
NOISE_MAX = 255
NOISE_SALT_CHANCE = 0.04
NOISE_PEPPER_CHANCE = 0.03
# --- color bars (SMPTE-inspired, sized for small matrices) ---
# 75% bars (classic broadcast look, not blown-out 100%)
_BAR_W = 191 # ~75% of 255
COLOR_BARS_TOP = (
(_BAR_W, _BAR_W, _BAR_W), # gray / white
(_BAR_W, _BAR_W, 0), # yellow
(0, _BAR_W, _BAR_W), # cyan
(0, _BAR_W, 0), # green
(_BAR_W, 0, _BAR_W), # magenta
(_BAR_W, 0, 0), # red
(0, 0, _BAR_W), # blue
)
# Lower strip: reverse blues + pluge-ish blacks/grays (simplified SMPTE bottom)
COLOR_BARS_BOT = (
(0, 0, _BAR_W), # blue
(16, 16, 16), # black
(_BAR_W, 0, _BAR_W), # magenta
(16, 16, 16), # black
(0, _BAR_W, _BAR_W), # cyan
(16, 16, 16), # black
(_BAR_W, _BAR_W, _BAR_W), # white
)
# Fraction of frame height for the main bars (rest = bottom strip)
COLOR_BARS_TOP_FRAC = 0.70
COLOR_BARS_FPS = 10 # static pattern; light refresh for stop_event
# Channel bug (lower-right overlay) — number climbs as channels change
CH_COLOR = (0, 220, 40)
CH_MARGIN_X = 1
CH_MARGIN_Y = 1
CH_PAD = 1 # padding around glyphs for dim plate
CH_BG_ALPHA = 0.55 # 0=clear, 1=solid black plate under text
CH_BG_TINT = (0, 25, 0) # slight green-black tint in the plate
CH_START = 2 # dial range CH2–CH15
CH_MAX = 15
CHANNEL_CLOCK = 5 # special: centered digital clock
CHANNEL_NEWS = 8 # special: news video + silly ticker
CHANNEL_WEATHER = 13 # special: scrolling weather report (WeatherClock / wttr.in)
CHANNEL_GIF_MIN = 14 # CH14–CH15: random GIFs from images/
CHANNEL_GIF_MAX = 15
# --- CH8 news ---
NEWS_VIDEO_NAME = "yt_qHJi8SMFrec_60s.mp4"
# Prefer panel bake (runtime library); fall back to source if present
NEWS_VIDEO_PATH = os.path.join(PANEL_VIDEO_DIR, NEWS_VIDEO_NAME)
# Specialty channels use channel_dwell_sec() (global limit); keep alias for clarity
NEWS_PLAY_SEC = None # None → channel_dwell_sec()
NEWS_FPS = 30 # fixed pace; higher rate + 1px steps = smooth, no flicker
NEWS_TICKER_H = 6 # bottom bar (LEDarcade banner font is 5px tall)
NEWS_TICKER_RGB = (255, 220, 40) # yellow crawl text
NEWS_TICKER_BG = (0, 0, 0)
NEWS_TICKER_BG_ALPHA = 0.78
# Always 1 pixel per frame (2px jumps looked too fast / jumpy)
# 1 @ 30fps ≈ 30 px/s — between the slow 24 and fast 48
NEWS_TICKER_PX_PER_FRAME = 1
NEWS_TICKER_GAP = " -- " # between headlines (banner font safe chars)
NEWS_TICKER_V = None # set at runtime: HEIGHT - 5
# 100 very silly news headlines for the bottom ticker
NEWS_HEADLINES = [
"Local squirrel elected mayor after promising free acorns for all",
"Scientists confirm toast always lands butter-side down on purpose",
"Man teaches goldfish to play chess, loses in three moves",
"City bans clouds for blocking nice views, weather refuses",
"New study finds cats have been ignoring humans since forever",
"Breaking: pizza delivery arrives early, town declares holiday",
"Robot vacuum forms union, demands better under-couch conditions",
"Woman's houseplant files for emotional support animal status",
"Experts warn of critical shortage of left socks nationwide",
"Duck walks into library, checks out all books on flying",
"Time traveler from 1998 shocked Wi-Fi is still not free everywhere",
"Local man invents self-buttering toast, butter files lawsuit",
"Moon briefly goes dark for maintenance, back online at dawn",
"Pigeons demand bike lanes, start mid-air traffic circles",
"Grandma's cookies classified as controlled substances by FDA",
"Computer refuses to work until complimented on its fonts",
"Town's only stoplight gains sentience, chooses perpetual yellow",
"Scientists discover the remote was under the cushion all along",
"Breaking: dogs confirm they do understand 'no' and ignore it",
"Alien visitors leave after sampling gas station coffee",
"New app rates your sandwich construction on a scale of 1-pickle",
"Local bee gets lost, invents a better GPS for hives",
"Man claims his shadow clocked out early on Friday",
"Study: 87% of meetings could have been a single raised eyebrow",
"Escaped circus balloon holds press conference about freedom",
"Wi-Fi password found carved into ancient stone tablet",
"Cat knocks mug off table for science, peer review pending",
"City installs upside-down street signs to confuse tourists less",
"Bread rises in revolt, flour negotiates peace deal",
"Local weatherperson predicts 'vibes', accuracy hits 100%",
"Fridge light unionizes, refuses to work with door closed",
"Man completes to-do list, reality glitches briefly",
"Snail wins marathon after all other racers get distracted",
"Breaking: the other sock was in the dryer the whole time",
"New law requires all puns to come with a warning label",
"Cloud named Kevin refuses to rain on weekends",
"Dog learns to open fridge, starts meal-prep business",
"Scientists measure how long 'one more episode' actually is",
"Local bridge files complaint about people taking it for granted",
"Toaster achieves enlightenment, only burns existential crumbs",
"Woman's GPS leads her to destiny, which is a taco truck",
"Potted plant starts podcast about window views",
"Breaking: Monday cancelled due to lack of interest",
"Man builds time machine, uses it only to skip commercials",
"Ducks form synchronized swimming team without asking anyone",
"Study finds pillows absorb 3 dreams per night on average",
"Traffic cone runs for senate on a platform of visibility",
"Local coffee shop accidentally invents time travel latte",
"Spider in corner of room named interim CEO of dust",
"Kids' lemonade stand accepts crypto, crashes the market",
"Breaking: all the missing pens found in a parallel drawer",
"Owl holds night class on staring contests, enrollment full",
"Man's alarm clock goes on strike for better snooze benefits",
"Garden gnome spotted migrating south for the winter",
"New diet consists entirely of foods that look like other foods",
"Elevator music gains critical acclaim, wins Grammy",
"Local raccoon opens Michelin-star trash tasting menu",
"Scientists prove the shortest distance is through the snack aisle",
"Umbrella achieves free will, only opens on sunny days",
"Breaking: soap opera characters discover they are on a soap opera",
"Man invents quieter keyboard, typing becomes too peaceful",
"Lawn flamingo leads peaceful protest for better mulch",
"Study: people who say 'per my last email' age twice as fast",
"Mystery of missing remote solved by dog's honest testimony",
"Local cloud storage full, sky starts deleting old rainbows",
"Sandwich artist creates edible Mona Lisa, then eats it",
"Breaking: Friday the 13th rescheduled to a Tuesday",
"Hamster wheel powers small village for 12 glorious seconds",
"Man's beard gains sentience, demands its own pillow",
"Traffic reports now include emotional gridlock ratings",
"Penguin applies for remote work, prefers cold Zoom backgrounds",
"New phone update adds feature nobody asked for: jazz hands",
"Local legend of free parking turns out to be true, briefly",
"Scientists teach octopus to juggle, regret it immediately",
"Breaking: the 'close door' elevator button never worked",
"Cow jumps over moon, files flight plan next time",
"Man loses argument with autocorrect, issues public apology",
"Sidewalk cracks form map to buried treasure of bottle caps",
"Study finds leftover pizza tastes better in alternate timelines",
"Local mime speaks out, still no one can hear him",
"Robot lawnmower writes memoir: 'Cut Me Some Slack'",
"Breaking: all cars honk in B-flat during rush hour symphony",
"Squirrel files patent on innovative nut-hiding algorithm",
"Woman's house keys learn to hide better each morning",
"New yoga pose accidentally summons mild inconvenience",
"Fish discovers water is wet, publishes controversial paper",
"Local Wi-Fi named 'FBI Surveillance Van' finally lives up to it",
"Man sorts recycling wrong, bin starts passive-aggressive notes",
"Breaking: cloud looks exactly like itself, experts baffled",
"Toaster pastry declares independence from breakfast",
"Dog's tail wags so hard it generates renewable energy",
"Study: saying 'you too' to the waiter is a social superpower",
"Local pothole elected to fill vacant council seat",
"Invisible ink finally seen after years of dedication",
"Breaking: socks and sandals alliance signs peace accord",
"Man finds meaning of life on a receipt, loses receipt",
"Butterfly effect confirmed: sneeze in Ohio cancels picnic in Maine",
"Office printer demands sacrifice of one staple per page",
"Local hero returns shopping cart from the far corral",
"Scientists conclude everything is fine, probably, maybe",
]
def random_dwell_sec():
"""Seconds to stay on a channel before surfing to the next (fixed after warmup)."""
return channel_dwell_sec()
def channel_dwell_sec():
"""Post-warmup dwell: fixed CHANNEL_DWELL_SEC (default 10s)."""
return float(CHANNEL_DWELL_SEC)
def is_gif_channel(channel):
n = int(channel)
return CHANNEL_GIF_MIN <= n <= CHANNEL_GIF_MAX
# --- CH5 clock look ---
_CLOCK_FONT_CANDIDATES = (
os.path.join(_HERE, "fonts", "Anton-Regular.ttf"), # bold display face
os.path.join(_HERE, "fonts", "3270-Regular.ttf"),
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
)
CLOCK_FONT_SIZE = 22 # big enough for 64x32 HH:MM
CLOCK_RGB = (0, 230, 90) # bright green time
CLOCK_SHADOW_RGB = (0, 35, 12)
CLOCK_SHADOW_OX = 1 # shadow offset right
CLOCK_SHADOW_OY = 1 # shadow offset down
CLOCK_FORMAT = "%H:%M" # 24h; update once per second
# --- Skyfall-style title drop ("LEDTV") ---
TITLE_WORD = "LEDTV"
TITLE_LETTER_ZOOM = 2
TITLE_LETTER_GAP = 1
TITLE_LETTER_RGB = (220, 40, 40) # bright TV red
TITLE_LETTER_SHADOW_RGB = (40, 8, 8)
TITLE_LETTER_STAGGER = 0.22 # seconds between letter drops
TITLE_LETTER_GRAVITY = 0.62
TITLE_LETTER_BOUNCE_DAMP = 0.44
TITLE_LETTER_SETTLE_V = 0.38
TITLE_LETTER_MAX_BOUNCES = 4 # a few bounces into vertical center
TITLE_HOLD_SECONDS = 1.6 # hold after all settled
TITLE_INTRO_MAX_SECONDS = 16.0
TITLE_TARGET_FPS = 30.0
TITLE_SIM_REFERENCE_DT = 1.0 / 60.0 # match Skyfall motion scaling
TITLE_MAX_SIM_DT = 1.0 / 12.0
# End-of-session: color bars then fade to black
END_COLOR_BARS_SEC = 3.5
END_FADE_SEC = 1.5
END_FADE_STEPS = 24
# 5x5 bitmap font (bit 4 = leftmost column)
_FONT_5X5 = {
"C": (
0b01110,
0b10001,
0b10000,
0b10001,
0b01110,
),
"H": (
0b10001,
0b10001,
0b11111,
0b10001,
0b10001,
),
"0": (
0b01110,
0b10001,
0b10001,
0b10001,
0b01110,
),
"1": (
0b01100,
0b00100,
0b00100,
0b00100,
0b01110,
),
"2": (
0b01110,
0b10001,
0b00110,
0b01000,
0b11111,
),
"3": (
0b11110,
0b00001,
0b01110,
0b00001,
0b11110,
),
"4": (
0b10010,
0b10010,
0b11111,
0b00010,
0b00010,
),
"5": (
0b11111,
0b10000,
0b11110,
0b00001,
0b11110,
),
"6": (
0b01110,
0b10000,
0b11110,
0b10001,
0b01110,
),
"7": (
0b11111,
0b00001,
0b00010,
0b00100,
0b00100,
),
"8": (
0b01110,
0b10001,
0b01110,
0b10001,
0b01110,
),
"9": (
0b01110,
0b10001,
0b01111,
0b00001,
0b01110,
),
" ": (
0b00000,
0b00000,
0b00000,
0b00000,
0b00000,
),
}
_FW = 5
_FH = 5
_FGAP = 1
#------------------------------------------------------------------------------
# Drawing helpers
#------------------------------------------------------------------------------
def channel_label(number):
"""Format overlay text, e.g. CH2, CH12 (range CH_START–CH_MAX)."""
n = int(number)
span = CH_MAX - CH_START + 1
if n < CH_START or n > CH_MAX:
# wrap into [CH_START, CH_MAX]
n = CH_START + ((n - CH_START) % span)
return "CH{}".format(n)
def label_size(text):
n = len(text)
if n <= 0:
return 0, 0
return n * _FW + max(0, n - 1) * _FGAP, _FH
def channel_position(label):
"""Lower-right origin for a channel label string (glyph top-left)."""
lw, lh = label_size(label)
return max(0, WIDTH - lw - CH_MARGIN_X), max(0, HEIGHT - lh - CH_MARGIN_Y)
def channel_plate_rect(label):
"""Inclusive pixel box for the dim translucent plate under CHn."""
x0, y0 = channel_position(label)
lw, lh = label_size(label)
left = max(0, x0 - CH_PAD)
top = max(0, y0 - CH_PAD)
right = min(WIDTH - 1, x0 + lw - 1 + CH_PAD)
bottom = min(HEIGHT - 1, y0 + lh - 1 + CH_PAD)
return left, top, right, bottom
def _blend_dim(px, tint, alpha):
"""Blend pixel toward tint (translucent dim plate)."""
tr, tg, tb = tint
r, g, b = px
a = float(alpha)
return (
int(r * (1.0 - a) + tr * a),
int(g * (1.0 - a) + tg * a),
int(b * (1.0 - a) + tb * a),
)
def dim_plate_on_image(img, label, alpha=None, tint=None):
"""Darken a translucent plate under the channel label on a PIL RGB image."""
if alpha is None:
alpha = CH_BG_ALPHA
if tint is None:
tint = CH_BG_TINT
left, top, right, bottom = channel_plate_rect(label)
px = img.load()
for y in range(top, bottom + 1):
for x in range(left, right + 1):
px[x, y] = _blend_dim(px[x, y], tint, alpha)
def draw_label_on_image(img, text, x0, y0, color):
"""Draw 5x5 bitmap text onto a PIL RGB image."""
r, g, b = int(color[0]), int(color[1]), int(color[2])
px = img.load()
x = int(x0)
for ch in text.upper():
rows = _FONT_5X5.get(ch, _FONT_5X5[" "])
for row_i, bits in enumerate(rows):
yy = y0 + row_i
if yy < 0 or yy >= HEIGHT:
continue
for col in range(_FW):
if bits & (1 << (_FW - 1 - col)):
xx = x + col
if 0 <= xx < WIDTH:
px[xx, yy] = (r, g, b)
x += _FW + _FGAP
def apply_channel_bug(img, label, color=None):
"""Dim translucent plate + green channel text on a full-frame PIL image."""
if color is None:
color = CH_COLOR
dim_plate_on_image(img, label)
x0, y0 = channel_position(label)
draw_label_on_image(img, label, x0, y0, color)
return img
def draw_label(canvas, text, x0, y0, color):
"""Draw text on an rgbmatrix canvas (no translucent plate — prefer apply_channel_bug)."""
r, g, b = int(color[0]), int(color[1]), int(color[2])
x = int(x0)
for ch in text.upper():
rows = _FONT_5X5.get(ch, _FONT_5X5[" "])
for row_i, bits in enumerate(rows):
yy = y0 + row_i
if yy < 0 or yy >= HEIGHT:
continue
for col in range(_FW):
if bits & (1 << (_FW - 1 - col)):
xx = x + col
if 0 <= xx < WIDTH:
canvas.SetPixel(xx, yy, r, g, b)
x += _FW + _FGAP
def draw_channel_bug_canvas(canvas, label, color=None):
"""
Dim plate + text on canvas. Plate is a solid-ish dim green-black
(canvas has no GetPixel; true blend is for PIL paths).
"""
if color is None:
color = CH_COLOR
left, top, right, bottom = channel_plate_rect(label)
# Approximate translucent dark plate (fixed dim green-black)
pr = int(CH_BG_TINT[0] * CH_BG_ALPHA)
pg = int(CH_BG_TINT[1] * CH_BG_ALPHA + 15 * (1.0 - CH_BG_ALPHA))
pb = int(CH_BG_TINT[2] * CH_BG_ALPHA)
for y in range(top, bottom + 1):
for x in range(left, right + 1):
canvas.SetPixel(x, y, pr, pg, pb)
x0, y0 = channel_position(label)
draw_label(canvas, label, x0, y0, color)
def next_channel(current):
"""Advance channel number (wraps CH_MAX → CH_START)."""
return step_channel(current, +1)
def prev_channel(current):
"""Step channel number down (wraps CH_START → CH_MAX)."""
return step_channel(current, -1)
def step_channel(current, direction):
"""Move channel by +1 or -1 with wrap."""
n = int(current) + (1 if direction >= 0 else -1)
if n > CH_MAX:
n = CH_START
elif n < CH_START:
n = CH_MAX
return n
def fill_white_noise(canvas):
"""Paint a full frame of traditional analog white noise (grayscale snow)."""
img = make_white_noise_image()
if hasattr(canvas, "SetImage"):
canvas.SetImage(img, 0, 0)
else:
set_pixel = canvas.SetPixel
px = img.load()
for y in range(HEIGHT):
for x in range(WIDTH):
r, g, b = px[x, y]
set_pixel(x, y, r, g, b)
def make_white_noise_image():
"""Return a PIL RGB frame of analog snow."""
lum = np.random.randint(NOISE_MIN, NOISE_MAX + 1, size=(HEIGHT, WIDTH), dtype=np.uint8)
if NOISE_SALT_CHANCE > 0:
salt = np.random.random((HEIGHT, WIDTH)) < NOISE_SALT_CHANCE
lum[salt] = 255
if NOISE_PEPPER_CHANCE > 0:
pepper = np.random.random((HEIGHT, WIDTH)) < NOISE_PEPPER_CHANCE
lum[pepper] = 0
# stack to RGB
rgb = np.stack([lum, lum, lum], axis=-1)
return Image.fromarray(rgb, mode="RGB")
def _bar_spans(n_bars, width):
"""Split width into n_bars columns (last bar absorbs remainder)."""
base = width // n_bars
rem = width % n_bars
spans = []
x = 0
for i in range(n_bars):
w = base + (1 if i < rem else 0)
spans.append((x, x + w))
x += w
return spans
def make_color_bars_image():
"""
SMPTE-style off-air color bars as a PIL RGB image:
- Top ~70%: gray, yellow, cyan, green, magenta, red, blue
- Bottom ~30%: blue, black, magenta, black, cyan, black, white
"""
img = Image.new("RGB", (WIDTH, HEIGHT), (0, 0, 0))
px = img.load()
top_h = max(1, int(HEIGHT * COLOR_BARS_TOP_FRAC))
bot_y0 = top_h
top_spans = _bar_spans(len(COLOR_BARS_TOP), WIDTH)
for (x0, x1), (r, g, b) in zip(top_spans, COLOR_BARS_TOP):
for y in range(0, top_h):
for x in range(x0, x1):
px[x, y] = (r, g, b)
bot_spans = _bar_spans(len(COLOR_BARS_BOT), WIDTH)
for (x0, x1), (r, g, b) in zip(bot_spans, COLOR_BARS_BOT):
for y in range(bot_y0, HEIGHT):
for x in range(x0, x1):
px[x, y] = (r, g, b)
return img
def fill_color_bars(canvas):
img = make_color_bars_image()
if hasattr(canvas, "SetImage"):
canvas.SetImage(img, 0, 0)
else:
set_pixel = canvas.SetPixel
px = img.load()
for y in range(HEIGHT):
for x in range(WIDTH):
r, g, b = px[x, y]
set_pixel(x, y, r, g, b)
def _run_loop(
duration_minutes,
stop_event,
fps,
paint_frame,
name,
channel=None,
clear_on_exit=True,
show_channel_bug=True,
):
"""Shared canvas loop: paint → SwapOnVSync → pace → honor stop/duration."""
if channel is None:
channel = CH_START
label = channel_label(channel)
print("[LEDtv] {} ({}x{}, {:.1f} min, {})".format(
name, WIDTH, HEIGHT, float(duration_minutes), label,
))
canvas = LED.Canvas
matrix = LED.TheMatrix
frame_dt = 1.0 / float(fps)
start = time.time()
next_frame = start
label_x, label_y = channel_position(label)
while True:
if stop_event is not None and stop_event.is_set():
print("[LEDtv] StopEvent received")
break
now = time.time()
if (now - start) / 60.0 >= duration_minutes:
print("[LEDtv] Duration reached ({:.1f} min)".format((now - start) / 60.0))
break
# Build frame in PIL so channel plate can truly blend (translucent)
if paint_frame is fill_white_noise:
img = make_white_noise_image()
elif paint_frame is fill_color_bars:
img = make_color_bars_image()
else:
img = Image.new("RGB", (WIDTH, HEIGHT), (0, 0, 0))
paint_frame(_PilCanvas(img))
if show_channel_bug:
apply_channel_bug(img, label)
# No Clear() — avoids a black flash hitch between noise frames
canvas.SetImage(img, 0, 0)
canvas = matrix.SwapOnVSync(canvas)
LED.Canvas = canvas
next_frame += frame_dt
sleep_for = next_frame - time.time()
if sleep_for > 0.0005:
time.sleep(sleep_for)
else:
# Keep cadence; don't spin if we're late
next_frame = time.time()
if clear_on_exit:
LED.ClearBigLED()
print("[LEDtv] {} exit".format(name))
class _PilCanvas(object):
"""Minimal canvas shim so paint helpers can write into a PIL image."""
def __init__(self, img):
self._img = img
self._px = img.load()
def SetPixel(self, x, y, r, g, b):
if 0 <= x < WIDTH and 0 <= y < HEIGHT:
self._px[x, y] = (int(r), int(g), int(b))
def SetImage(self, img, h=0, v=0):
self._img.paste(img, (int(h), int(v)))
def Clear(self):
self._img.paste((0, 0, 0), (0, 0, WIDTH, HEIGHT))
#------------------------------------------------------------------------------
# Effects
#------------------------------------------------------------------------------
def play_white_noise(
duration_minutes, stop_event=None, channel=None,
clear_on_exit=True, show_channel_bug=True,
):
"""Classic TV static with green channel bug in the lower-right corner."""
_run_loop(
duration_minutes, stop_event, NOISE_FPS,
fill_white_noise, "White noise", channel=channel or CH_START,
clear_on_exit=clear_on_exit, show_channel_bug=show_channel_bug,
)
def play_color_bars(
duration_minutes, stop_event=None, channel=None,
clear_on_exit=True, show_channel_bug=True,
):
"""SMPTE-style color bars (station off the air) with green channel bug."""
_run_loop(
duration_minutes, stop_event, COLOR_BARS_FPS,
fill_color_bars, "Color bars", channel=channel or CH_START,
clear_on_exit=clear_on_exit, show_channel_bug=show_channel_bug,
)
#------------------------------------------------------------------------------
# Skyfall-style "LEDTV" title drop
#------------------------------------------------------------------------------
class _TitleLetter(object):
"""Single banner letter that drops, bounces, and rests on the bottom row."""
def __init__(self, char, pixels, shadow_pixels, width, height, rest_x, rest_y, drop_delay):
self.char = char
self.pixels = pixels
self.shadow_pixels = shadow_pixels
self.width = width
self.height = height
self.rest_x = rest_x
self.rest_y = rest_y
self.drop_delay = drop_delay
self.x = float(rest_x)
self.y = float(-height - 6)
self.vy = 0.0
self.dropped = False
self.settled = False
self.bounce_count = 0
def update(self, step, elapsed, gravity, bounce_damp, settle_v, max_bounces):
if self.settled:
self.y = self.rest_y
return
if elapsed < self.drop_delay:
return
self.dropped = True
self.vy += gravity * step
self.y += self.vy * step
if self.y >= self.rest_y:
self.y = self.rest_y
if abs(self.vy) < settle_v or self.bounce_count >= max_bounces:
self.vy = 0.0
self.settled = True
else:
self.vy = -abs(self.vy) * bounce_damp
self.bounce_count += 1
def force_settle(self):
self.x = float(self.rest_x)
self.y = float(self.rest_y)
self.vy = 0.0
self.dropped = True
self.settled = True
def draw(self, canvas):
sx = int(round(self.x))
sy = int(round(self.y))
set_pixel = canvas.SetPixel
for dx, dy, rgb in self.shadow_pixels:
px = sx + dx
py = sy + dy
if 0 <= px < WIDTH and 0 <= py < HEIGHT:
set_pixel(px, py, rgb[0], rgb[1], rgb[2])
for dx, dy, rgb in self.pixels:
px = sx + dx
py = sy + dy
if 0 <= px < WIDTH and 0 <= py < HEIGHT:
set_pixel(px, py, rgb[0], rgb[1], rgb[2])
def _letter_sprite(char):
ch = char.upper()
if not ("A" <= ch <= "Z"):
return None
idx = ord(ch) - ord("A")
try:
return LED.TrimSprite(copy.deepcopy(LED.AlphaSpriteList[idx]))
except Exception:
return None
def _sprite_pixels_zoomed(sprite, zoom, rgb, shadow_rgb):
"""Expand banner sprite pixels — each lit cell becomes a zoom×zoom block."""
pixels = []
shadow_pixels = []
sw, sh = sprite.width, sprite.height
for count in range(sw * sh):
if sprite.grid[count] == 0:
continue
y, x = divmod(count, sw)
for zv in range(zoom):
for zh in range(zoom):
pixels.append((x * zoom + zh, y * zoom + zv, rgb))
shadow_pixels.append((x * zoom + zh + 1, y * zoom + zv + 1, shadow_rgb))
return pixels, shadow_pixels, sw * zoom, sh * zoom
def _build_title_letters(width, height):
specs = []
for char in TITLE_WORD:
sprite = _letter_sprite(char)
if sprite is None:
continue
pixels, shadow_pixels, letter_w, letter_h = _sprite_pixels_zoomed(
sprite, TITLE_LETTER_ZOOM, TITLE_LETTER_RGB, TITLE_LETTER_SHADOW_RGB,
)
specs.append((char, pixels, shadow_pixels, letter_w, letter_h))
if not specs:
return []
total_width = sum(s[3] for s in specs) + TITLE_LETTER_GAP * max(0, len(specs) - 1)
start_x = max(0, (width - total_width) // 2)
letter_height = max(s[4] for s in specs)
# Rest / bounce floor = vertically centered (not the bottom row)
rest_y = max(0, (height - letter_height) // 2)
letters = []
x_cursor = start_x
for index, (char, pixels, shadow_pixels, letter_w, letter_h) in enumerate(specs):
y_offset = letter_height - letter_h
letters.append(_TitleLetter(
char, pixels, shadow_pixels, letter_w, letter_h,
x_cursor, rest_y + y_offset,
drop_delay=index * TITLE_LETTER_STAGGER,
))
x_cursor += letter_w + TITLE_LETTER_GAP
return letters
def play_title_drop(stop_event=None):
"""
Drop "LEDTV" one letter at a time from the sky — Skyfall-style fall + bounce,
but settle vertically centered on the panel (bounce up into place).
"""
letters = _build_title_letters(WIDTH, HEIGHT)
if not letters:
print("[LEDtv] Title drop skipped (no letter sprites)")
return
print("[LEDtv] Title intro — dropping '{}'".format(TITLE_WORD))
try:
canvas = LED.TheMatrix.CreateFrameCanvas()
except Exception:
canvas = LED.Canvas
start = time.time()
last_frame = start
hold_start = None
frame_period = 1.0 / TITLE_TARGET_FPS
try:
while True:
if stop_event is not None and stop_event.is_set():
break
now = time.time()
elapsed = now - start
if elapsed >= TITLE_INTRO_MAX_SECONDS:
for letter in letters:
letter.force_settle()
break
dt = now - last_frame
last_frame = now
# Same motion scaling as Skyfall: speeds stay consistent across FPS
if dt <= 0.0:
step = 1.0
else:
step = min(dt, TITLE_MAX_SIM_DT) / TITLE_SIM_REFERENCE_DT
for letter in letters:
letter.update(
step, elapsed,
TITLE_LETTER_GRAVITY, TITLE_LETTER_BOUNCE_DAMP,
TITLE_LETTER_SETTLE_V, TITLE_LETTER_MAX_BOUNCES,
)
if hold_start is None and all(letter.settled for letter in letters):
hold_start = now
canvas.Clear()
for letter in letters:
if letter.dropped or letter.settled:
letter.draw(canvas)
canvas = LED.TheMatrix.SwapOnVSync(canvas)
LED.Canvas = canvas
if hold_start is not None and (now - hold_start) >= TITLE_HOLD_SECONDS:
break
# Soft pace toward target FPS
spent = time.time() - now
if spent < frame_period:
time.sleep(frame_period - spent)
except KeyboardInterrupt:
pass
# Brief black beat before static
try:
canvas.Clear()
canvas = LED.TheMatrix.SwapOnVSync(canvas)
LED.Canvas = canvas
except Exception:
LED.ClearBigLED()
time.sleep(0.15)
print("[LEDtv] Title intro done")
def play_static_seconds(seconds, stop_event=None, channel=None, show_channel_bug=True):
"""Play white noise for a fixed number of seconds (not minutes)."""
if seconds is None or seconds <= 0:
return
mins = float(seconds) / 60.0
play_white_noise(
mins, stop_event=stop_event, channel=channel,
clear_on_exit=False, show_channel_bug=show_channel_bug,
)
def _clock_font(size=None):
"""Load a display font for the clock channel (cached per size)."""