diff --git a/.gitignore b/.gitignore index 71242a5..54392d9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ $RECYCLE.BIN/ # Editors *.swp *~ + +# Imported still_alive text (tools/import_still_alive.py) +src/still_alive/imported.h diff --git a/README.md b/README.md index a90fa69..b9804f6 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,14 @@ truth; if anything here disagrees with it, the datasheet wins. ## Layout ``` -platformio.ini build config (esp32dev, Arduino, Adafruit GFX) +platformio.ini build config: envs still_alive (default) and esp32dev include/pins.h pin map -src/main.cpp test sketch: cycles through test images +src/still_alive/ Portal-style end-credits demo (env still_alive) +src/test_images/ test sketch: cycles through 12 test images (env esp32dev) lib/EL512/ local library src/EL512Panel.* framebuffer, row encoder, I2S DMA refresh src/EL512_GFX.* Adafruit_GFX subclass +tools/ import_still_alive.py test/ unit tests (placeholder) docs/ datasheet and notes ``` @@ -79,19 +81,29 @@ docs/ datasheet and notes ## Build and flash Requires [PlatformIO](https://platformio.org/) (CLI or the VS Code extension). +The upload and monitor port is set to COM20 in `platformio.ini`. ```bash -pio run -``` - -```bash -pio run -t upload -``` - -```bash +pio run -e still_alive -t upload # end-credits demo +pio run -e esp32dev -t upload # test images pio device monitor ``` +## Still Alive demo + +A recreation of Portal's ending: timed song text typed into a console on the +left, credits scrolling top right, ASCII art bottom right. Without extra data it +plays placeholder text. To import the original text, timing, art and credits from +[SDSkyKlouD/still-alive-web](https://github.com/SDSkyKlouD/still-alive-web): + +```bash +python tools/import_still_alive.py +pio run -e still_alive -t upload +``` + +This writes `src/still_alive/imported.h`, which is git-ignored. Reset the board and +start the song when the serial log says (7.75 s after reset with the imported data). + ## Using the driver ```cpp diff --git a/platformio.ini b/platformio.ini index 1a3f999..c86701d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,7 +1,10 @@ ; Planar EL512.256-H electroluminescent panel driver for ESP32 (ESP-WROOM-32) ; https://docs.platformio.org/page/projectconf.html -[env:esp32dev] +[platformio] +default_envs = still_alive + +[env] platform = espressif32@6.10.0 board = esp32dev framework = arduino @@ -12,3 +15,11 @@ lib_deps = adafruit/Adafruit GFX Library build_flags = -Wall + +; Test images: cycles through 12 labelled test patterns. +[env:esp32dev] +build_src_filter = + + +; Portal-style end-credits console. +[env:still_alive] +build_src_filter = + diff --git a/src/still_alive/art.h b/src/still_alive/art.h new file mode 100644 index 0000000..1d58b82 --- /dev/null +++ b/src/still_alive/art.h @@ -0,0 +1,76 @@ +// ASCII pictures for the bottom-right box (41 columns x 20 rows fit). +// Each picture is centred in the box. Used when there is no imported.h. +#pragma once + +#include "show_data.h" + +static const char* const ART_CAKE_LINES[] = { + " ) ) )", + " (_) (_) (_)", + " | | |", + " .---|-----|-----|---.", + " |~~~~~~~~~~~~~~~~~~~|", + " | o o o o |", + " |~~~~~~~~~~~~~~~~~~~|", + " | |", + " '-------------------'", +}; + +static const char* const ART_HEART_LINES[] = { + " .-\"\"\"-. .-\"\"\"-.", + " / \\ / \\", + " | V |", + " \\ /", + " '. .'", + " '. .'", + " '. .'", + " '. .'", + " V", +}; + +static const char* const ART_CHECK_LINES[] = { + " ##", + " ###", + " ###", + " ## ###", + " ### ###", + " ### ###", + " ###", + "", + " TEST PASSED", +}; + +static const char* const ART_PANEL_LINES[] = { + " .--------------------------.", + " | EL512.256-H 512 x 256 |", + " | |", + " | VCLK 5.0 MHz |", + " | HS 256 + 16 clk |", + " | VS 70.4 Hz |", + " | |", + " '--------------------------'", + " | | | | |", + " ESP32 I2S0 DMA", +}; + +#define EL_ART(a) {a, sizeof(a) / sizeof(a[0])} + +// Index with the ART_* ids below. +static const Art ARTS[] = { + {nullptr, 0}, + EL_ART(ART_CAKE_LINES), + EL_ART(ART_HEART_LINES), + EL_ART(ART_CHECK_LINES), + EL_ART(ART_PANEL_LINES), +}; + +#undef EL_ART + +static constexpr size_t ART_COUNT = sizeof(ARTS) / sizeof(ARTS[0]); + +enum : int8_t { + ART_CAKE = 1, + ART_HEART, + ART_CHECK, + ART_PANEL, +}; diff --git a/src/still_alive/credits.h b/src/still_alive/credits.h new file mode 100644 index 0000000..bb8c642 --- /dev/null +++ b/src/still_alive/credits.h @@ -0,0 +1,41 @@ +// Placeholder credits, used when there is no imported.h. Typed into the +// top-right box one line at a time, scrolling up, looping. +// The box is 41 columns wide; longer lines wrap. +#pragma once + +#include +#include + +static constexpr uint32_t CREDITS_START_MS = 0; +static constexpr uint32_t CREDIT_CHAR_US = 68624; // Portal's credits speed +static constexpr bool CREDITS_LOOP = true; + +static const char* const CREDITS[] = { + "", + "PLANAR EL512.256-H", + "512 x 256 electroluminescent", + "", + "Driven by ......... ESP32", + "Refresh ........... I2S0 + DMA", + "Pixel clock ....... 5.0 MHz", + "Frame rate ........ 70.4 Hz", + "Stream ............ 142 KB", + "Graphics .......... Adafruit GFX", + "", + "Bugs found and fixed:", + " BCK at half the byte rate", + " VCLK on the wrong edge", + " one very loose wire", + "", + "Known issues:", + " row stripes go whoosh", + " some burn-in, it is old", + "", + "No panels were harmed", + "in the making of this demo.", + "", + "", + "", +}; + +static constexpr size_t CREDIT_COUNT = sizeof(CREDITS) / sizeof(CREDITS[0]); diff --git a/src/still_alive/lyrics.h b/src/still_alive/lyrics.h new file mode 100644 index 0000000..7a4cb18 --- /dev/null +++ b/src/still_alive/lyrics.h @@ -0,0 +1,31 @@ +// Placeholder console text, used when there is no imported.h. +// +// To play the real song text with its original timing, run +// python tools/import_still_alive.py +// which writes imported.h next to this file (see the script for details). +// +// Or write your own steps here: each one starts at startMs (from the start of +// the show), is typed out evenly over durMs, and can start a new line after it +// (STEP_NEWLINE), clear the console first (STEP_CLEAR) and switch the picture. +// The console is 41 columns x 31 rows; longer lines wrap. +#pragma once + +#include "art.h" +#include "show_data.h" + +static constexpr uint32_t SONG_START_MS = 0; // when to start the music + +static const LyricStep LYRICS[] = { + {0, 2600, STEP_NEWLINE, ART_PANEL, "Forms EL-512256: Panel Bring-up Report"}, + {3000, 0, STEP_NEWLINE, ART_KEEP, ""}, + {3500, 1400, STEP_NEWLINE, ART_KEEP, "Your lyrics go here."}, + {5500, 2100, STEP_NEWLINE, ART_KEEP, "Run tools/import_still_alive.py"}, + {8000, 2600, STEP_NEWLINE, ART_CHECK, "to import them with the song's timing,"}, + {11000, 2300, STEP_NEWLINE, ART_KEEP, "or edit src/still_alive/lyrics.h."}, + {15000, 0, STEP_CLEAR, ART_KEEP, ""}, + {15500, 2300, STEP_NEWLINE, ART_HEART, "Each step starts at its startMs and"}, + {18000, 1500, STEP_NEWLINE, ART_KEEP, "is typed out over durMs."}, + {21000, 2100, STEP_NEWLINE, ART_CAKE, "Reset the board to start over."}, +}; + +static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]); diff --git a/src/still_alive/main.cpp b/src/still_alive/main.cpp new file mode 100644 index 0000000..c070b9a --- /dev/null +++ b/src/still_alive/main.cpp @@ -0,0 +1,192 @@ +// Portal-style end-credits console on the EL512.256-H: +// timed lyrics on the left, typed credits top right, ASCII art bottom right. +// +// Content: imported.h if present (generated by tools/import_still_alive.py), +// otherwise the placeholder lyrics.h + art.h + credits.h. +#include +#include +#include + +#if __has_include("imported.h") +#include "imported.h" +#else +#include "art.h" +#include "credits.h" +#include "lyrics.h" +#endif +#include "pins.h" +#include "text_box.h" + +static EL512Panel panel({ + el512pins::VCLK, + el512pins::VID, + el512pins::TVID, + el512pins::HS, + el512pins::VS, + el512pins::ENABLE, + el512pins::LOWPWR_N, +}); +static EL512_GFX display(panel); + +static constexpr uint32_t START_DELAY_MS = 1000; // after boot or restart +static constexpr uint32_t CURSOR_BLINK_MS = 300; +static constexpr uint32_t RESTART_MS = 10000; // after the last step + +// 41 x 31 lyrics console | 41 x 9 credits over a 41 x 20 picture. +static TextBox lyricsBox(0, 0, 254, 256); +static TextBox creditsBox(256, 0, 256, 86); +static TextBox artBox(256, 88, 256, 168); + +static uint32_t showStart = 0; +static size_t step = 0; +static size_t charIdx = 0; +static bool stepStarted = false; +static uint32_t finishedAt = 0; + +static size_t creditIdx = 0; +static size_t creditChar = 0; +static bool creditLineOpen = false; +static bool creditsDone = false; +static uint32_t creditLineAt = 0; // show time the current credits line started + +static bool due(uint32_t now, uint32_t at) { return (int32_t)(now - at) >= 0; } + +static void showArt(int8_t id) { + artBox.clear(); + if (id <= ART_NONE || (size_t)id >= ART_COUNT) return; + const Art& art = ARTS[id]; + size_t width = 0; + for (uint8_t i = 0; i < art.count; i++) width = std::max(width, strlen(art.lines[i])); + const uint8_t top = art.count < artBox.rows() ? (artBox.rows() - art.count) / 2 : 0; + const uint8_t left = width < artBox.cols() ? (artBox.cols() - width) / 2 : 0; + for (uint8_t r = 0; r < top; r++) artBox.put('\n'); + for (uint8_t i = 0; i < art.count && top + i < artBox.rows(); i++) { + if (i > 0) artBox.put('\n'); + for (uint8_t c = 0; c < left; c++) artBox.put(' '); + // Truncate instead of wrapping so a wide picture keeps its shape. + for (const char* p = art.lines[i]; *p && left + (p - art.lines[i]) < artBox.cols(); p++) + artBox.put(*p); + } +} + +static void startShow(uint32_t now) { + lyricsBox.clear(); + creditsBox.clear(); + showArt(ART_NONE); + showStart = now + START_DELAY_MS; + step = 0; + charIdx = 0; + stepStarted = false; + finishedAt = 0; + creditIdx = 0; + creditChar = 0; + creditLineOpen = false; + creditsDone = false; + creditLineAt = CREDITS_START_MS; + // Credits are typed on the bottom row and scroll up, as in the game. + for (uint8_t r = 1; r < creditsBox.rows(); r++) creditsBox.put('\n'); +} + +// Type whatever the clock says should be on screen by now; true if anything changed. +static bool updateLyrics(uint32_t now) { + if (!due(now, showStart)) return false; + const uint32_t t = now - showStart; + bool changed = false; + while (step < LYRIC_COUNT && t >= LYRICS[step].startMs) { + const LyricStep& s = LYRICS[step]; + if (!stepStarted) { + stepStarted = true; + if (s.flags & STEP_CLEAR) lyricsBox.clear(); + if (s.art != ART_KEEP) showArt(s.art); + changed = true; + } + const size_t len = strlen(s.text); + const uint32_t elapsed = t - s.startMs; + const size_t want = (s.durMs == 0 || elapsed >= s.durMs) ? len : len * elapsed / s.durMs; + while (charIdx < want) { + lyricsBox.put(s.text[charIdx++]); + changed = true; + } + if (charIdx < len) break; // still typing this step + if (s.flags & STEP_NEWLINE) lyricsBox.put('\n'); + step++; + charIdx = 0; + stepStarted = false; + changed = true; + if (step == LYRIC_COUNT) finishedAt = now; + } + return changed; +} + +// Credits: each line takes CREDIT_CHAR_US per character (an empty line counts as +// one), typed evenly, then the next line starts on a fresh bottom row. +static bool updateCredits(uint32_t now) { + if (creditsDone || !due(now, showStart)) return false; + const uint32_t t = now - showStart; + if (t < CREDITS_START_MS) return false; + bool changed = false; + while (true) { + const char* line = CREDITS[creditIdx]; + const size_t len = strlen(line); + if (!creditLineOpen) { + creditLineOpen = true; + creditsBox.put('\n'); + changed = true; + } + const uint32_t dur = (uint32_t)((uint64_t)CREDIT_CHAR_US * (len ? len : 1) / 1000); + const uint32_t elapsed = t - creditLineAt; + const size_t want = elapsed >= dur ? len : len * elapsed / dur; + while (creditChar < want) { + creditsBox.put(line[creditChar++]); + changed = true; + } + if (elapsed < dur) break; + creditLineAt += dur; + creditChar = 0; + creditLineOpen = false; + if (++creditIdx == CREDIT_COUNT) { + creditIdx = 0; + if (!CREDITS_LOOP) { + creditsDone = true; + break; + } + } + } + return changed; +} + +void setup() { + Serial.begin(115200); + Serial.println("EL512.256-H still alive"); + if (!display.begin()) { + Serial.println("panel init failed (out of DMA memory?)"); + while (true) delay(1000); + } + Serial.printf("%u lyric steps, %u pictures, %u credit lines\n", (unsigned)LYRIC_COUNT, + (unsigned)ART_COUNT, (unsigned)CREDIT_COUNT); + Serial.printf("start the song %lu ms after reset\n", + (unsigned long)(START_DELAY_MS + SONG_START_MS)); + startShow(millis()); +} + +void loop() { + static bool cursorWas = false; + const uint32_t now = millis(); + + if (step == LYRIC_COUNT && due(now, finishedAt + RESTART_MS)) startShow(now); + + bool dirty = updateLyrics(now); + dirty |= updateCredits(now); + const bool cursor = (now / CURSOR_BLINK_MS) & 1; + dirty |= cursor != cursorWas; + cursorWas = cursor; + + if (dirty) { + display.fillScreen(0); + lyricsBox.draw(display, cursor); + creditsBox.draw(display, cursor && !creditsDone && due(now, showStart + CREDITS_START_MS)); + artBox.draw(display, false, false); // no frame around the picture, as in the game + display.display(); + } + delay(2); +} diff --git a/src/still_alive/show_data.h b/src/still_alive/show_data.h new file mode 100644 index 0000000..6ad738b --- /dev/null +++ b/src/still_alive/show_data.h @@ -0,0 +1,28 @@ +// Data types shared by the hand-written show (lyrics.h + art.h) and the imported +// one (imported.h, generated by tools/import_still_alive.py). +#pragma once + +#include +#include + +enum : uint8_t { + STEP_NEWLINE = 1 << 0, // start a new line after the text is typed + STEP_CLEAR = 1 << 1, // clear the console before typing +}; + +// One timed piece of console text. Steps are sorted by startMs. +struct LyricStep { + uint32_t startMs; // from the start of the show + uint16_t durMs; // the text is typed out evenly over this time + uint8_t flags; + int8_t art; // picture to show (index into ARTS), or ART_KEEP + const char* text; +}; + +struct Art { + const char* const* lines; + uint8_t count; +}; + +static constexpr int8_t ART_KEEP = -1; // leave the current picture +static constexpr int8_t ART_NONE = 0; // ARTS[0] is always the empty picture diff --git a/src/still_alive/text_box.h b/src/still_alive/text_box.h new file mode 100644 index 0000000..c07999f --- /dev/null +++ b/src/still_alive/text_box.h @@ -0,0 +1,89 @@ +// Fixed-size character console in the GFX default 6x8 font, inside a dashed frame. +// Text wraps at the right edge and scrolls up when it runs past the bottom row. +#pragma once + +#include +#include + +class TextBox { + public: + static constexpr uint8_t MAX_COLS = 48; + static constexpr uint8_t MAX_ROWS = 32; + static constexpr int16_t PAD = 4; + static constexpr int16_t CHAR_W = 6; + static constexpr int16_t CHAR_H = 8; + + TextBox(int16_t x, int16_t y, int16_t w, int16_t h) + : _x(x), _y(y), _w(w), _h(h), _cols(fit(w, CHAR_W, MAX_COLS)), _rows(fit(h, CHAR_H, MAX_ROWS)) { + clear(); + } + + uint8_t cols() const { return _cols; } + uint8_t rows() const { return _rows; } + + void clear() { + memset(_text, 0, sizeof(_text)); + _row = 0; + _col = 0; + } + + void put(char c) { + if (c == '\n') { + newLine(); + return; + } + if (_col >= _cols) newLine(); + _text[_row][_col++] = c; + } + + void print(const char* s) { + while (*s) put(*s++); + } + + void draw(Adafruit_GFX& g, bool cursor, bool frame = true) const { + if (frame) drawFrame(g); + g.setTextSize(1); + g.setTextColor(1); + g.setTextWrap(false); + for (uint8_t r = 0; r < _rows; r++) { + g.setCursor(_x + PAD, _y + PAD + r * CHAR_H); + g.print(_text[r]); + } + if (cursor) g.fillRect(_x + PAD + _col * CHAR_W, _y + PAD + _row * CHAR_H + 6, 5, 2, 1); + } + + private: + static uint8_t fit(int16_t px, int16_t cell, uint8_t cap) { + const int n = (px - 2 * PAD) / cell; + return n > cap ? cap : n; + } + + void newLine() { + _col = 0; + if (_row + 1 < _rows) { + _row++; + return; + } + memmove(_text[0], _text[1], (size_t)(_rows - 1) * sizeof(_text[0])); + memset(_text[_rows - 1], 0, sizeof(_text[0])); + } + + // Dashed outline, 4 px on / 2 px off. + void drawFrame(Adafruit_GFX& g) const { + for (int16_t i = 0; i < _w; i += 6) { + const int16_t n = std::min(4, _w - i); + g.drawFastHLine(_x + i, _y, n, 1); + g.drawFastHLine(_x + i, _y + _h - 1, n, 1); + } + for (int16_t i = 0; i < _h; i += 6) { + const int16_t n = std::min(4, _h - i); + g.drawFastVLine(_x, _y + i, n, 1); + g.drawFastVLine(_x + _w - 1, _y + i, n, 1); + } + } + + int16_t _x, _y, _w, _h; + uint8_t _cols, _rows; + uint8_t _row = 0, _col = 0; + char _text[MAX_ROWS][MAX_COLS + 1]; +}; diff --git a/src/main.cpp b/src/test_images/main.cpp similarity index 100% rename from src/main.cpp rename to src/test_images/main.cpp diff --git a/tools/import_still_alive.py b/tools/import_still_alive.py new file mode 100644 index 0000000..b320d64 --- /dev/null +++ b/tools/import_still_alive.py @@ -0,0 +1,186 @@ +#!/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
is one screen +(the console clears when it starts). Inside it, are typed lines with times relative to +the screen, and
are extra blank lines. ASCII art comes +from
 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'
(.*?)
', 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'
', 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()