- background page (4th tile): built-in or any GIF in /gifs on the SD card - chosen GIF is copied into a raw 11.9 MB 'bggif' flash partition (header written last) and played memory-mapped from flash; SD only needed when choosing or when the file changed (checked at boot in the background) - own partitions.csv: 4 MB app + bggif + NVS - tools/gif_to_360.py: convert GIFs to 360x360 keeping frame timing; converted output (12-OBD-Speed/sd/) is git-ignored Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
72 lines
2.6 KiB
Python
72 lines
2.6 KiB
Python
"""Convert GIFs to 360x360 for the JC3636W518 (round 360x360 display).
|
|
|
|
Each frame is scaled so the GIF covers 360x360, then center-cropped. Frame durations and looping are kept.
|
|
Pillow stores only the changed part of each frame, which keeps files small.
|
|
|
|
Usage:
|
|
pip install pillow
|
|
python gif_to_360.py <input.gif or folder> [more ...] -o <output folder>
|
|
|
|
The device stores one background GIF in an 11.5 MB flash area: bigger results are reported as too large.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
from PIL import Image, ImageSequence
|
|
|
|
SIZE = 360
|
|
MAX_BYTES = 11_500_000
|
|
|
|
|
|
def convert(src, dst):
|
|
im = Image.open(src)
|
|
w, h = im.size
|
|
scale = max(SIZE / w, SIZE / h)
|
|
nw, nh = max(SIZE, round(w * scale)), max(SIZE, round(h * scale))
|
|
left, top = (nw - SIZE) // 2, (nh - SIZE) // 2
|
|
|
|
frames, durations = [], []
|
|
for frame in ImageSequence.Iterator(im):
|
|
durations.append(frame.info.get("duration", im.info.get("duration", 100)) or 100)
|
|
rgba = frame.convert("RGBA") # fully composited frame
|
|
if (nw, nh) != (w, h):
|
|
rgba = rgba.resize((nw, nh), Image.LANCZOS)
|
|
rgba = rgba.crop((left, top, left + SIZE, top + SIZE))
|
|
# the device shows the GIF on black: flatten transparency onto black
|
|
bg = Image.new("RGBA", rgba.size, (0, 0, 0, 255))
|
|
bg.alpha_composite(rgba)
|
|
frames.append(bg.convert("RGB"))
|
|
|
|
frames[0].save(dst, save_all=True, append_images=frames[1:], duration=durations,
|
|
loop=im.info.get("loop", 0), optimize=True, disposal=1)
|
|
size = os.path.getsize(dst)
|
|
fps = len(durations) * 1000 / max(1, sum(durations))
|
|
note = "" if size <= MAX_BYTES else " ** TOO LARGE for the device (max 11.5 MB) **"
|
|
print(f"{os.path.basename(src)}: {w}x{h}, {len(frames)} frames, {fps:.1f} fps "
|
|
f"-> {os.path.basename(dst)} {size / 1e6:.2f} MB{note}")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("inputs", nargs="+", help="GIF files or folders")
|
|
ap.add_argument("-o", "--out", required=True, help="output folder")
|
|
args = ap.parse_args()
|
|
os.makedirs(args.out, exist_ok=True)
|
|
|
|
files = []
|
|
for p in args.inputs:
|
|
if os.path.isdir(p):
|
|
files += [os.path.join(p, f) for f in sorted(os.listdir(p)) if f.lower().endswith(".gif")]
|
|
else:
|
|
files.append(p)
|
|
if not files:
|
|
sys.exit("no GIF files found")
|
|
for f in files:
|
|
name = os.path.splitext(os.path.basename(f))[0]
|
|
convert(f, os.path.join(args.out, name + ".gif"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|