Files
planar-EL512.256-H/tools/import_still_alive.py
T
mangavalkandClaude Opus 5.5 329f97a5ec Add Portal-style Still Alive demo and split build envs
- src/still_alive: timed console text, typed scrolling credits and ASCII art,
  laid out for 512x256 (41x31 console, 41x9 credits, 41x20 picture).
  Plays placeholder content unless imported.h exists.
- tools/import_still_alive.py: generates the git-ignored imported.h (text,
  timing, art, credits) from SDSkyKlouD/still-alive-web.
- platformio.ini: envs still_alive (default) and esp32dev (test images,
  moved to src/test_images).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:15:21 +02:00

187 lines
7.7 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Import the console text, credits, timing and ASCII art for the still_alive demo.
Reads index.html and res/stillalive.js from
https://github.com/SDSkyKlouD/still-alive-web (downloaded, or local copies) and
writes src/still_alive/imported.h, which the firmware uses instead of the
placeholder lyrics.h / art.h / credits.h when it exists.
python tools/import_still_alive.py # download from GitHub
python tools/import_still_alive.py --html index.html --js stillalive.js
python tools/import_still_alive.py --out somewhere/imported.h
imported.h is listed in .gitignore, so the imported text stays on your machine.
Source format: each <div class="container_lyrics" data-start=ms> is one screen
(the console clears when it starts). Inside it, <span data-start data-dur
data-append-br data-text data-asciiart> are typed lines with times relative to
the screen, and <br data-show-offset=ms> are extra blank lines. ASCII art comes
from <pre class="asciiart_NAME"> blocks. The credits are the CREDIT_DATA array in
stillalive.js; the page starts typing them 9 s in and starts the song 6.75 s in.
"""
import argparse
import html
import re
import sys
import urllib.request
from pathlib import Path
SOURCE_BASE = "https://raw.githubusercontent.com/SDSkyKlouD/still-alive-web/master/"
OUT = Path(__file__).resolve().parent.parent / "src" / "still_alive" / "imported.h"
# Timing from stillalive.js.
SONG_START_MS = 6750
CREDITS_START_MS = 9000
CREDIT_CHAR_US = 68624 # 68.623562 ms per character
CONSOLE_COLS = 41
CONSOLE_ROWS = 31
ART_COLS = 41
ART_ROWS = 20
# The panel font is plain ASCII; fold the few typographic characters.
FOLD = {"‘": "'", "’": "'", "“": '"', "”": '"',
"–": "-", "—": "-", "…": "...", " ": " "}
def to_ascii(s):
s = "".join(FOLD.get(c, c) for c in s)
return "".join(c if 32 <= ord(c) < 127 else "?" for c in s)
def c_string(s):
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
def attr(tag, name):
m = re.search(r'\b%s="([^"]*)"' % re.escape(name), tag)
return html.unescape(m.group(1)) if m else None
def load(path, remote):
if path:
return Path(path).read_text(encoding="utf-8")
with urllib.request.urlopen(SOURCE_BASE + remote) as r:
return r.read().decode("utf-8")
def parse_credits(script):
m = re.search(r"const CREDIT_DATA = \[(.*?)\n\];", script, re.S)
if not m:
return []
# Each entry is a double-quoted JS string; undo its escapes.
return [to_ascii(re.sub(r"\\(.)", r"\1", s))
for s in re.findall(r'"((?:[^"\\]|\\.)*)"', m.group(1))]
def parse_art(page):
arts = {}
for m in re.finditer(r'<pre class="asciiart_(\w+)">(.*?)</pre>', page, re.S):
lines = [to_ascii(l.rstrip()) for l in html.unescape(m.group(2)).split("\n")]
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
arts[m.group(1)] = lines
return arts
def parse_steps(page, art_ids):
# (start, order, dur, flags, art, text); order keeps source order on equal times.
steps = []
screens = list(re.finditer(r'<div class="container_lyrics[^"]*" data-start="(\d+)">', page))
for i, screen in enumerate(screens):
base = int(screen.group(1))
end = screens[i + 1].start() if i + 1 < len(screens) else len(page)
body = page[screen.end():end]
steps.append((base, len(steps), 0, "STEP_CLEAR", "ART_KEEP", ""))
for tag in re.finditer(r"<(span|br)\b[^>]*>", body):
t = tag.group(0)
if tag.group(1) == "br":
offset = attr(t, "data-show-offset")
if offset is not None:
steps.append((base + int(offset), len(steps), 0, "STEP_NEWLINE", "ART_KEEP", ""))
continue
text = attr(t, "data-text")
if text is None or attr(t, "data-start") is None:
continue
flags = "STEP_NEWLINE" if attr(t, "data-append-br") == "true" else "0"
art = attr(t, "data-asciiart")
art_id = "ART_KEEP" if art is None else ("ART_NONE" if art == "clear" else str(art_ids.get(art, 0)))
steps.append((base + int(attr(t, "data-start")), len(steps),
min(int(attr(t, "data-dur") or 0), 65535), flags, art_id, to_ascii(text)))
steps.sort(key=lambda s: (s[0], s[1]))
return steps
def check(steps, arts):
"""Warn about anything that will not fit the panel layout."""
rows = 0
col = 0
for start, _, _, flags, _, text in steps:
if flags == "STEP_CLEAR":
rows, col = 0, 0
col += len(text)
if len(text) > CONSOLE_COLS:
print(f"warning: line at {start} ms is {len(text)} chars, wraps", file=sys.stderr)
if flags == "STEP_NEWLINE":
rows += 1
col = 0
if rows == CONSOLE_ROWS:
print(f"warning: screen overflows {CONSOLE_ROWS} rows at {start} ms, scrolls",
file=sys.stderr)
for name, lines in arts.items():
if len(lines) > ART_ROWS or max(map(len, lines), default=0) > ART_COLS:
print(f"warning: art '{name}' is larger than {ART_COLS}x{ART_ROWS}, gets cut",
file=sys.stderr)
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--html", help="local index.html (default: download)")
ap.add_argument("--js", help="local res/stillalive.js (default: download)")
ap.add_argument("--out", type=Path, default=OUT, help="output file (default: %(default)s)")
args = ap.parse_args()
page = load(args.html, "index.html")
arts = parse_art(page)
art_ids = {name: i + 1 for i, name in enumerate(arts)}
steps = parse_steps(page, art_ids)
credits = parse_credits(load(args.js, "res/stillalive.js"))
if not steps or not arts or not credits:
sys.exit("error: no lyrics, art or credits found; has the source changed?")
check(steps, arts)
out = ["// Generated by tools/import_still_alive.py from",
"// https://github.com/SDSkyKlouD/still-alive-web - do not edit.",
"#pragma once", "", '#include "show_data.h"', ""]
for name, lines in arts.items():
out.append(f"static const char* const ART_{name.upper()}_LINES[] = {{")
out += [f" {c_string(l)}," for l in lines]
out += ["};", ""]
out.append("static const Art ARTS[] = {")
out.append(" {nullptr, 0},")
out += [f" {{ART_{n.upper()}_LINES, {len(l)}}}, // {art_ids[n]}" for n, l in arts.items()]
out += ["};", "static constexpr size_t ART_COUNT = sizeof(ARTS) / sizeof(ARTS[0]);", ""]
out.append("static const LyricStep LYRICS[] = {")
out += [f" {{{s}, {d}, {f}, {a}, {c_string(t)}}}," for s, _, d, f, a, t in steps]
out += ["};", "static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]);", ""]
out.append("static const char* const CREDITS[] = {")
out += [f" {c_string(c)}," for c in credits]
out += ["};", "static constexpr size_t CREDIT_COUNT = sizeof(CREDITS) / sizeof(CREDITS[0]);", ""]
out += [f"static constexpr uint32_t SONG_START_MS = {SONG_START_MS};",
f"static constexpr uint32_t CREDITS_START_MS = {CREDITS_START_MS};",
f"static constexpr uint32_t CREDIT_CHAR_US = {CREDIT_CHAR_US};",
"static constexpr bool CREDITS_LOOP = false;", ""]
args.out.write_text("\n".join(out), encoding="utf-8", newline="\n")
screens = sum(1 for s in steps if s[3] == "STEP_CLEAR")
print(f"wrote {args.out}: {len(steps)} steps on {screens} screens, {len(arts)} pictures, "
f"{len(credits)} credit lines, {steps[-1][0] / 1000:.0f} s")
if __name__ == "__main__":
main()