From 8f31bdd977e713bad21b7fab31b14ce8edcd2db2 Mon Sep 17 00:00:00 2001 From: Rekushi <170186554+RekuNote@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:40:41 +0100 Subject: [PATCH] Add files via upload --- LICENSE | 21 ++ sppm-js/README.md | 19 ++ sppm-js/index.js | 347 ++++++++++++++++++++++++++ sppm-js/package.json | 10 + sppm-js/test/sppm.test.js | 47 ++++ sppm-python/README.md | 22 ++ sppm-python/requirements.txt | 4 + sppm-python/sppm_tools.py | 440 +++++++++++++++++++++++++++++++++ sppm-python/test_sppm_tools.py | 80 ++++++ 9 files changed, 990 insertions(+) create mode 100644 LICENSE create mode 100644 sppm-js/README.md create mode 100644 sppm-js/index.js create mode 100644 sppm-js/package.json create mode 100644 sppm-js/test/sppm.test.js create mode 100644 sppm-python/README.md create mode 100644 sppm-python/requirements.txt create mode 100644 sppm-python/sppm_tools.py create mode 100644 sppm-python/test_sppm_tools.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..316f8e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RexiMemo! + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sppm-js/README.md b/sppm-js/README.md new file mode 100644 index 0000000..ebfd078 --- /dev/null +++ b/sppm-js/README.md @@ -0,0 +1,19 @@ +# SPPM JavaScript tools + +Dependency-free ES module for Node.js 20+ and modern browsers. + +```js +import { readSppm, getSppmMetadata, getSppmThumbnail, SppmPlayer } from "./index.js"; + +const note = await readSppm("my-note.sppm"); +console.log(await getSppmMetadata("my-note.sppm")); +const { width, height, data } = await getSppmThumbnail("my-note.sppm"); +const player = new SppmPlayer(document.querySelector("canvas"), note); +await player.play(); // starts at the first frame; includes BGM and frame-cued effects +``` + +`readSppm` validates the container and CRCs. Current Ed25519 `SPP3` signatures +are verified through WebCrypto when supported. Legacy `SPPS` HMAC files are +reported as unverified unless `legacyHmacKey` is provided. This module reads and +renders notes; it does not create or sign SPPM files. The thumbnail is RGBA with +a solid white background by default and uses the note's cover frame. diff --git a/sppm-js/index.js b/sppm-js/index.js new file mode 100644 index 0000000..ab1a18b --- /dev/null +++ b/sppm-js/index.js @@ -0,0 +1,347 @@ +const WIDTH = 640, HEIGHT = 360, PIXELS = WIDTH * HEIGHT; +const HEADER_SIZE = 36, MAX_FILE_SIZE = 512 * 1024 * 1024; +const PUBLIC_FOOTER_SIZE = 4 + 3 * 48 + 64; + +function fail(message) { throw new Error(`Invalid SPPM: ${message}`); } + +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return (crc ^ 0xffffffff) >>> 0; +} + +async function inputBytes(input) { + if (input instanceof Uint8Array) return input; + if (input instanceof ArrayBuffer) return new Uint8Array(input); + if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + if (typeof Blob !== "undefined" && input instanceof Blob) return new Uint8Array(await input.arrayBuffer()); + if (typeof input === "string") { + const { readFile } = await import("node:fs/promises"); + return new Uint8Array(await readFile(input)); + } + fail("expected a path, Blob, ArrayBuffer, or Uint8Array"); +} + +class Reader { + constructor(bytes, end = bytes.length) { this.bytes = bytes; this.view = new DataView(bytes.buffer, bytes.byteOffset, end); this.at = 0; this.end = end; } + need(size) { if (!Number.isSafeInteger(size) || size < 0 || size > this.end - this.at) fail("truncated payload"); } + u8() { this.need(1); return this.view.getUint8(this.at++); } + u16() { this.need(2); const v = this.view.getUint16(this.at, true); this.at += 2; return v; } + u32() { this.need(4); const v = this.view.getUint32(this.at, true); this.at += 4; return v; } + u64() { this.need(8); const v = this.view.getBigUint64(this.at, true); this.at += 8; return v <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(v) : v.toString(); } + take(size) { this.need(size); const result = this.bytes.subarray(this.at, this.at + size); this.at += size; return result; } + string(max) { + const size = this.u16(); if (size > max) fail("metadata string exceeds limit"); + const bytes = this.take(size); + if (bytes.includes(0)) fail("metadata contains NUL"); + try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } + catch { fail("metadata is not valid UTF-8"); } + } + id(optional = false) { + const bytes = this.take(16), value = String.fromCharCode(...bytes); + if (optional && value === "0000000000000000") return ""; + if (!/^[0-9A-Fa-f]{16}$/.test(value) || value === "0000000000000000") fail("invalid lineage ID"); + return value.toUpperCase(); + } +} + +function decodeLayer(encoded) { + const out = new Uint8Array(PIXELS); let src = 0, dst = 0; + while (src < encoded.length) { + const command = encoded[src++], count = (command & 0x7f) + 1; + if (dst + count > PIXELS) fail("RLE layer expands beyond canvas size"); + if (command & 0x80) { + if (src >= encoded.length) fail("truncated RLE repeat packet"); + out.fill(encoded[src++], dst, dst + count); + } else { + if (src + count > encoded.length) fail("truncated RLE literal packet"); + out.set(encoded.subarray(src, src + count), dst); src += count; + } + dst += count; + } + if (dst !== PIXELS) fail("RLE layer has incorrect decoded size"); + return out; +} + +function validateLayer(encoded) { + let src = 0, dst = 0; + while (src < encoded.length) { + const command = encoded[src++], count = (command & 0x7f) + 1; + if (dst + count > PIXELS) fail("RLE layer expands beyond canvas size"); + if (command & 0x80) { + if (src >= encoded.length) fail("truncated RLE repeat packet"); + src++; + } else { + if (src + count > encoded.length) fail("truncated RLE literal packet"); + src += count; + } + dst += count; + } + if (dst !== PIXELS) fail("RLE layer has incorrect decoded size"); +} +const decodedLayerCache = new WeakMap(); +function decodedLayer(encoded) { + let decoded = decodedLayerCache.get(encoded); + if (!decoded) { decoded = decodeLayer(encoded); decodedLayerCache.set(encoded, decoded); } + return decoded; +} + +function migrateColor(r, g, b, a) { + if (a && r === 26 && g === 40 && b === 72) return [132, 66, 183, a]; + if (a && r === 92 && g === 224 && b === 36) return [239, 68, 133, a]; + return [r, g, b, a]; +} + +async function getWebCrypto() { + if (globalThis.crypto?.subtle) return globalThis.crypto; + try { return (await import("node:crypto")).webcrypto; } + catch { return null; } +} + +async function verifyV3(body, footer) { + const reader = new Reader(footer); + if (String.fromCharCode(...reader.take(4)) !== "SPP3") fail("invalid public-signature marker"); + const identities = []; + for (let i = 0; i < 3; i++) identities.push({ publicKey: reader.take(32), deviceTag: reader.take(16) }); + const signature = reader.take(64); + if (reader.at !== reader.end || !identities[2].publicKey.some(Boolean) || !identities[2].deviceTag.some(Boolean)) fail("invalid public identity footer"); + const crypto = await getWebCrypto(); + if (!crypto) return { status: "present-unverified", identities }; + try { + const domain = new TextEncoder().encode("FlipnoteNX-SPPM-public-signature-v3\0"); + const concat = new Uint8Array(domain.length + body.length + 3 * 48); + let at = 0; concat.set(domain, at); at += domain.length; concat.set(body, at); at += body.length; + for (const identity of identities) { concat.set(identity.publicKey, at); at += 32; concat.set(identity.deviceTag, at); at += 16; } + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", concat)); + const key = await crypto.subtle.importKey("raw", identities[2].publicKey, { name: "Ed25519" }, false, ["verify"]); + const valid = await crypto.subtle.verify({ name: "Ed25519" }, key, signature, digest); + if (!valid) fail("public signature verification failed"); + return { status: "verified", identities }; + } catch (error) { + if (String(error?.message || error).includes("public signature verification failed")) throw error; + return { status: "present-unverified", identities, verificationError: String(error?.message || error) }; + } +} + +async function verifyLegacyHmac(body, signature, key) { + if (!key) return "present-unverified"; + const crypto = await getWebCrypto(); if (!crypto) return "present-unverified"; + const rawKey = key instanceof Uint8Array ? key : new Uint8Array(key); + const hmacKey = await crypto.subtle.importKey("raw", rawKey, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); + return await crypto.subtle.verify("HMAC", hmacKey, signature, body) ? "verified" : fail("legacy HMAC verification failed"); +} + +/** Parse a complete SPPM file. Public SPP3 signatures are verified where WebCrypto supports Ed25519. */ +export async function readSppm(input, { legacyHmacKey = null } = {}) { + const bytes = await inputBytes(input); + if (bytes.length < HEADER_SIZE || bytes.length > MAX_FILE_SIZE + PUBLIC_FOOTER_SIZE + 36) fail("file size outside supported limits"); + if (String.fromCharCode(...bytes.subarray(0, 4)) !== "SPPM") fail("bad file magic"); + + let bodyEnd = bytes.length, signature = { status: "unsigned" }, publicIdentities = null; + const tail = bytes.length >= 4 ? String.fromCharCode(...bytes.subarray(-4)) : ""; + if (tail === "SPP3") fail("public signature footer is truncated or malformed"); + const publicAt = bytes.length - PUBLIC_FOOTER_SIZE; + if (publicAt >= HEADER_SIZE && String.fromCharCode(...bytes.subarray(publicAt, publicAt + 4)) === "SPP3") { + bodyEnd = publicAt; + const checked = await verifyV3(bytes.subarray(0, bodyEnd), bytes.subarray(publicAt)); + signature = { status: checked.status, algorithm: "Ed25519", identities: checked.identities, verificationError: checked.verificationError }; + publicIdentities = checked.identities; + } else if (bytes.length >= HEADER_SIZE + 36 && String.fromCharCode(...bytes.subarray(-36, -32)) === "SPPS") { + bodyEnd = bytes.length - 36; + signature = { status: await verifyLegacyHmac(bytes.subarray(0, bodyEnd), bytes.subarray(-32), legacyHmacKey), algorithm: "HMAC-SHA-256" }; + } + + const body = bytes.subarray(0, bodyEnd); + const r = new Reader(body); + r.take(4); + const version = r.u16(), headerSize = r.u16(), totalBytes = r.u64(), expectedCrc = r.u32(); + const width = r.u16(), height = r.u16(), layerCount = r.u8(), paletteCount = r.u16(), fps = r.u8(), flags = r.u8(), frameCount = r.u32(), audioCount = r.u8(), reserved = r.u16(); + if ((version !== 1 && version !== 2 && version !== 3) || headerSize !== HEADER_SIZE || totalBytes !== body.length || width !== WIDTH || height !== HEIGHT || layerCount !== 4 || paletteCount !== 256 || fps < 1 || fps > 30 || frameCount < 1 || frameCount > 9999 || audioCount > 4 || reserved !== 0 || ((version === 1 ? flags & ~3 : flags & ~15) !== 0)) fail("unsupported SPPM header"); + if (crc32(body.subarray(HEADER_SIZE)) !== expectedCrc) fail("payload CRC mismatch"); + + const paletteBytes = r.take(256 * 4), palette = new Array(256); + for (let i = 0; i < 256; i++) palette[i] = migrateColor(...paletteBytes.subarray(i * 4, i * 4 + 4)); + const metadata = { + title: r.string(256), description: r.string(4096), authorName: r.string(128), originalAuthorName: r.string(128), lastEditorName: r.string(128), + authorFsid: r.id(), originalAuthorFsid: r.id(), lastEditorFsid: r.id(), currentId: r.id(), rootId: r.id(), parentId: r.id(true), + createdUnixSeconds: r.u64(), modifiedUnixSeconds: r.u64(), coverFrame: r.u32(), width, height, fps, + locked: !!(flags & 1), allowSpinoffs: !!(flags & 2), bgmFollowsSpeed: version === 1 || !!(flags & 4), loopPlayback: version === 1 ? true : !(flags & 8), + frameCount, audioTrackCount: audioCount, formatVersion: version, signature: signature.status, + }; + if (metadata.coverFrame >= frameCount) fail("cover frame outside animation"); + if (publicIdentities) { + metadata.identities = publicIdentities.map(identity => ({ publicKeyHex: hex(identity.publicKey), deviceTagHex: hex(identity.deviceTag) })); + const crypto = await getWebCrypto(); + if (crypto) for (const [field, index] of [["authorFsid", 0], ["originalAuthorFsid", 1], ["lastEditorFsid", 2]]) { + const identity = publicIdentities[index]; + if (!identity.publicKey.some(Boolean) && !identity.deviceTag.some(Boolean)) continue; + const domain = new TextEncoder().encode("FlipnoteNX-public-fsid-v2\0"); + const source = new Uint8Array(domain.length + 48); source.set(domain); source.set(identity.publicKey, domain.length); source.set(identity.deviceTag, domain.length + 32); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", source)); + if (metadata[field] !== hex(digest.subarray(0, 8)).toUpperCase()) fail(`${field} does not match signed public identity`); + } + } + + const frames = []; + for (let fi = 0; fi < frameCount; fi++) { + const layers = []; + for (let li = 0; li < 4; li++) { + const codec = r.u8(), length = r.u32(), expected = r.u32(); + if (codec !== 1 || length < 1 || length > 460800) fail("unsupported or oversized layer"); + const encoded = r.take(length); + if (crc32(encoded) !== expected) fail(`layer ${li} CRC mismatch in frame ${fi}`); + validateLayer(encoded); + layers.push(encoded); + } + frames.push({ layers }); + } + const audioTracks = []; + let audioSamples = 0; + for (let ti = 0; ti < audioCount; ti++) { + const index = r.u8(), channels = r.u8(), referenceFps = version >= 2 ? r.u8() : 0, sampleRate = r.u32(), name = r.string(64), triggerCount = r.u16(); + if (index !== ti || (channels !== 1 && channels !== 2) || sampleRate < 8000 || sampleRate > 48000 || triggerCount > frameCount || (ti === 0 && triggerCount !== 0)) fail("invalid audio metadata"); + const triggers = Array.from({ length: triggerCount }, () => r.u16()); + for (let i = 0; i < triggers.length; i++) if (triggers[i] >= frameCount || (i && triggers[i] <= triggers[i - 1])) fail("invalid sound-effect frame cues"); + const sampleCount = r.u32(), pcmCrc = r.u32(); audioSamples += sampleCount; + if (sampleCount > 32 * 1024 * 1024 - (audioSamples - sampleCount) || sampleCount % channels !== 0) fail("audio sample count outside supported limits"); + const pcmBytes = r.take(sampleCount * 2); + if (crc32(pcmBytes) !== pcmCrc) fail("audio PCM CRC mismatch"); + const pcm = new Int16Array(sampleCount); + const sampleView = new DataView(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength); + for (let i = 0; i < sampleCount; i++) pcm[i] = sampleView.getInt16(i * 2, true); + audioTracks.push({ index, name, channels, referenceFps: ti === 0 && version === 1 ? fps : referenceFps, sampleRate, frameTriggers: triggers, pcm }); + } + if (r.at !== body.length) fail("unexpected trailing bytes"); + return { width, height, palette, metadata, frames, audioTracks, signature }; +} + +function hex(bytes) { return [...bytes].map(value => value.toString(16).padStart(2, "0")).join(""); } + +/** Read only the public metadata fields from an SPPM file. */ +export async function getSppmMetadata(input, options) { + return (await readSppm(input, options)).metadata; +} + +/** Composite a frame to RGBA bytes, with layer A at the front and D at the back. */ +export function renderFrame(note, frameIndex, { background = [255, 255, 255, 255] } = {}) { + if (!note?.frames?.length || frameIndex < 0 || frameIndex >= note.frames.length) throw new RangeError("frameIndex outside note"); + const out = new Uint8ClampedArray(PIXELS * 4), frame = note.frames[frameIndex], layers = frame.layers.map(decodedLayer), [br, bg, bb, ba] = background; + for (let p = 0; p < PIXELS; p++) { + let r = br, g = bg, b = bb, a = ba; + for (let layer = 3; layer >= 0; layer--) { + const colorIndex = layers[layer][p]; if (colorIndex === 0) continue; + const [sr, sg, sb, sa] = note.palette[colorIndex]; if (!sa) continue; + const sourceWeight = sa * 255, behindWeight = a * (255 - sa), total = sourceWeight + behindWeight; + if (!total) { r = g = b = a = 0; continue; } + r = Math.floor((sr * sourceWeight + r * behindWeight + total / 2) / total); + g = Math.floor((sg * sourceWeight + g * behindWeight + total / 2) / total); + b = Math.floor((sb * sourceWeight + b * behindWeight + total / 2) / total); + a = Math.floor((total + 127) / 255); + } + const at = p * 4; out[at] = r; out[at + 1] = g; out[at + 2] = b; out[at + 3] = a; + } + return out; +} + +/** Get an RGBA thumbnail for the note's designated cover frame. */ +export async function getSppmThumbnail(input, { width = 320, height = 180, background = [255, 255, 255, 255], ...readOptions } = {}) { + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width > 4096 || height > 4096) throw new RangeError("thumbnail dimensions must be 1..4096"); + const note = await readSppm(input, readOptions), source = renderFrame(note, note.metadata.coverFrame, { background }); + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const sx = Math.min(WIDTH - 1, Math.floor(x * WIDTH / width)), sy = Math.min(HEIGHT - 1, Math.floor(y * HEIGHT / height)); + data.set(source.subarray((sy * WIDTH + sx) * 4, (sy * WIDTH + sx) * 4 + 4), (y * width + x) * 4); + } + return { width, height, data }; +} + +/** Draw the cover thumbnail into a browser canvas (keeps nearest-pixel sampling). */ +export async function drawSppmThumbnail(canvas, input, options = {}) { + const thumbnail = await getSppmThumbnail(input, { width: canvas.width || 320, height: canvas.height || 180, ...options }); + const context = canvas.getContext("2d"); + context.putImageData(new ImageData(thumbnail.data, thumbnail.width, thumbnail.height), 0, 0); + return thumbnail; +} + +/** Canvas animation playback, including SPPM BGM and frame-cued sound effects. */ +export class SppmPlayer { + constructor(canvas, note, { audio = true, context = null } = {}) { + if (!canvas?.getContext || !note?.frames?.length) throw new TypeError("SppmPlayer requires a canvas and a parsed note"); + this.canvas = canvas; this.note = note; this.audioEnabled = audio; this.audioContext = context; + this.frame = 0; this.playing = false; this.loop = note.metadata.loopPlayback; this.raf = 0; this.startedAt = 0; this.cycle = 0; this.sources = []; + canvas.width = note.width; canvas.height = note.height; + this.context2d = canvas.getContext("2d", { alpha: false }); + this.render(); + } + render() { + const rgba = renderFrame(this.note, this.frame); + this.context2d.putImageData(new ImageData(rgba, this.note.width, this.note.height), 0, 0); + this.onframechange?.(this.frame); + } + async play({ fromStart = true } = {}) { + if (fromStart) this.frame = 0; + if (this.playing && !fromStart) return; + this.#stopAudio(); this.render(); + this.playing = true; this.cycle = 0; this.startedAt = performance.now(); + if (this.audioEnabled) await this.#scheduleAudio(); + const tick = now => { + if (!this.playing) return; + const cycleMs = this.note.frames.length * 1000 / this.note.metadata.fps; + const elapsed = now - this.startedAt; + if (elapsed >= cycleMs) { + if (this.loop) { + this.cycle++; this.startedAt += cycleMs; this.#stopAudio(); + if (this.audioEnabled) this.#scheduleAudio().catch(error => this.onerror?.(error)); + } else { + this.frame = this.note.frames.length - 1; this.render(); this.playing = false; this.#stopAudio(); this.onended?.(); return; + } + } + const nextFrame = Math.min(this.note.frames.length - 1, Math.floor((now - this.startedAt) * this.note.metadata.fps / 1000)); + if (nextFrame !== this.frame) { this.frame = nextFrame; this.render(); } + this.raf = requestAnimationFrame(tick); + }; + this.raf = requestAnimationFrame(tick); + } + pause() { + if (!this.playing) return; + this.playing = false; cancelAnimationFrame(this.raf); + this.frame = Math.min(this.note.frames.length - 1, Math.floor((performance.now() - this.startedAt) * this.note.metadata.fps / 1000)); + this.#stopAudio(); this.render(); this.onpause?.(this.frame); + } + stop() { this.playing = false; cancelAnimationFrame(this.raf); this.frame = 0; this.#stopAudio(); this.render(); } + seek(frame) { + if (!Number.isInteger(frame) || frame < 0 || frame >= this.note.frames.length) throw new RangeError("frame outside note"); + this.frame = frame; this.render(); + if (this.playing) { this.startedAt = performance.now() - frame * 1000 / this.note.metadata.fps; this.#stopAudio(); if (this.audioEnabled) this.#scheduleAudio().catch(error => this.onerror?.(error)); } + } + destroy() { this.stop(); if (this.audioContext && !this.externalAudioContext) this.audioContext.close?.(); } + async #scheduleAudio() { + const AudioContextCtor = globalThis.AudioContext || globalThis.webkitAudioContext; + if (!this.audioContext) { if (!AudioContextCtor) return; this.audioContext = new AudioContextCtor(); } + await this.audioContext.resume?.(); + const start = this.audioContext.currentTime + 0.04; + const fps = this.note.metadata.fps; + for (const track of this.note.audioTracks) { + if (!track.pcm.length) continue; + const buffer = this.audioContext.createBuffer(track.channels, track.pcm.length / track.channels, track.sampleRate); + for (let ch = 0; ch < track.channels; ch++) { + const channel = buffer.getChannelData(ch); + for (let i = 0; i < channel.length; i++) channel[i] = track.pcm[i * track.channels + ch] / 32768; + } + if (track.index === 0) { + const source = this.audioContext.createBufferSource(); source.buffer = buffer; + if (this.note.metadata.bgmFollowsSpeed) source.playbackRate.value = fps / (track.referenceFps || fps); + source.loop = !!this.loop; source.connect(this.audioContext.destination); source.start(start); this.sources.push(source); + } else for (const cue of track.frameTriggers) { + const source = this.audioContext.createBufferSource(); source.buffer = buffer; source.connect(this.audioContext.destination); + source.start(start + cue / fps); this.sources.push(source); + } + } + } + #stopAudio() { for (const source of this.sources) { try { source.stop(); } catch {} try { source.disconnect(); } catch {} } this.sources = []; } +} + +export const SPPM = Object.freeze({ width: WIDTH, height: HEIGHT, layerCount: 4, paletteSize: 256 }); diff --git a/sppm-js/package.json b/sppm-js/package.json new file mode 100644 index 0000000..09f98ff --- /dev/null +++ b/sppm-js/package.json @@ -0,0 +1,10 @@ +{ + "name": "@switchnote/switchnote-tools", + "version": "1.0.0", + "description": "Read, verify, play, thumbnail, and convert Switchnote SPPM files", + "type": "module", + "exports": "./index.js", + "scripts": { "test": "node ./test/sppm.test.js" }, + "engines": { "node": ">=20" }, + "license": "MIT" +} diff --git a/sppm-js/test/sppm.test.js b/sppm-js/test/sppm.test.js new file mode 100644 index 0000000..5c1ec69 --- /dev/null +++ b/sppm-js/test/sppm.test.js @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getSppmMetadata, getSppmThumbnail, readSppm, renderFrame } from "../index.js"; + +function crc32(bytes) { + let crc = 0xffffffff; + for (const b of bytes) { crc ^= b; for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); } + return (crc ^ 0xffffffff) >>> 0; +} +function fixture() { + const parts = []; + const u8 = n => parts.push(Buffer.from([n & 255])); + const u16 = n => { const b = Buffer.alloc(2); b.writeUInt16LE(n); parts.push(b); }; + const u32 = n => { const b = Buffer.alloc(4); b.writeUInt32LE(n); parts.push(b); }; + const u64 = n => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); parts.push(b); }; + const str = s => { const b = Buffer.from(s); u16(b.length); parts.push(b); }; + parts.push(Buffer.from("SPPM")); u16(2); u16(36); u64(0); u32(0); u16(640); u16(360); u8(4); u16(256); u8(12); u8(2); u32(1); u8(0); u16(0); + const palette = Buffer.alloc(1024); palette.set([255, 0, 0, 255], 4); parts.push(palette); + ["JS fixture", "", "Tester", "Tester", "Tester"].forEach(str); + ["1111111111111111", "1111111111111111", "1111111111111111", "2222222222222222", "2222222222222222", "0000000000000000"].forEach(id => parts.push(Buffer.from(id))); + u64(100); u64(100); u32(0); + const zeroRun = Buffer.alloc(3600); for (let i = 0; i < zeroRun.length; i += 2) { zeroRun[i] = 255; zeroRun[i + 1] = 0; } + const firstLayer = Buffer.concat([Buffer.from([0, 1]), zeroRun.subarray(0, 3598), Buffer.from([254, 0])]); // one literal plus 230399 zeroes + for (let layer = 0; layer < 4; layer++) { + const encoded = layer === 0 ? firstLayer : zeroRun; + u8(1); u32(encoded.length); u32(crc32(encoded)); parts.push(encoded); + } + let body = Buffer.concat(parts); body.writeBigUInt64LE(BigInt(body.length), 8); body.writeUInt32LE(crc32(body.subarray(36)), 16); + return new Uint8Array(body); +} + +test("reads metadata, validates RLE and renders a white-backed cover thumbnail", async () => { + const bytes = fixture(); + const note = await readSppm(bytes); + assert.equal((await getSppmMetadata(bytes)).title, "JS fixture"); + assert.equal(note.frames.length, 1); + const rgba = renderFrame(note, 0); + assert.deepEqual([...rgba.subarray(0, 4)], [255, 0, 0, 255]); + const thumb = await getSppmThumbnail(bytes, { width: 2, height: 1 }); + assert.deepEqual([...thumb.data.subarray(0, 4)], [255, 0, 0, 255]); + assert.deepEqual([...thumb.data.subarray(4, 8)], [255, 255, 255, 255]); +}); + +test("rejects damaged payload CRC", async () => { + const bytes = fixture(); bytes[80] ^= 1; + await assert.rejects(readSppm(bytes), /CRC/); +}); diff --git a/sppm-python/README.md b/sppm-python/README.md new file mode 100644 index 0000000..5b1a4b7 --- /dev/null +++ b/sppm-python/README.md @@ -0,0 +1,22 @@ +# SPPM Python tools + +Python 3.10+ module. Install Pillow and NumPy with `python -m pip install -r requirements.txt`. +Install `cryptography` separately if Ed25519 signature verification is needed. +MP4 conversion also needs `ffmpeg` installed and available on `PATH`. + +```python +from sppm_tools import get_sppm_metadata, get_sppm_thumbnail, sppm_to_mp4 + +metadata = get_sppm_metadata("my-note.sppm") +thumbnail = get_sppm_thumbnail("my-note.sppm", "cover.png") +sppm_to_mp4("my-note.sppm", "animation.mp4") +``` + +Thumbnails use the note's selected cover frame and default to a solid white background. +Video exports composite layers D→C→B→A at the note's stored frame rate, with AAC audio +mixed from BGM and frame-cued sound effects. Structural bounds, payload CRCs, per-layer +RLE, audio CRCs, and frame cues are checked. Current SPP3 Ed25519 signatures are verified +when the optional `cryptography` dependency is installed; legacy SPPS HMAC signatures are +verified only if the caller passes `legacy_hmac_key`. This package does not write or sign +SPPM files. The format's `locked` metadata flag is not encryption; this reader can still +render and convert a locked note. diff --git a/sppm-python/requirements.txt b/sppm-python/requirements.txt new file mode 100644 index 0000000..b343e84 --- /dev/null +++ b/sppm-python/requirements.txt @@ -0,0 +1,4 @@ +Pillow>=10.0 +numpy>=1.24 +# Optional: install this to verify current SPP3 Ed25519 signatures. +# cryptography>=41.0 diff --git a/sppm-python/sppm_tools.py b/sppm-python/sppm_tools.py new file mode 100644 index 0000000..6a459e2 --- /dev/null +++ b/sppm-python/sppm_tools.py @@ -0,0 +1,440 @@ +"""Read, preview, and convert Switchnote Studio SPPM files.""" +from __future__ import annotations + +import array +import hashlib +import hmac +import os +import shutil +import struct +import subprocess +import tempfile +import wave +import zlib +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO, Optional, Union + +import numpy as np +from PIL import Image + +WIDTH, HEIGHT = 640, 360 +PIXELS = WIDTH * HEIGHT +HEADER_SIZE = 36 +MAX_FILE_SIZE = 512 * 1024 * 1024 +MAX_AUDIO_SAMPLES = 32 * 1024 * 1024 +PUBLIC_FOOTER_SIZE = 4 + 3 * 48 + 64 +Input = Union[str, os.PathLike, bytes, bytearray, memoryview] + + +class SppmError(ValueError): + """Malformed, unsupported, or damaged SPPM input.""" + + +@dataclass +class AudioTrack: + index: int + channels: int + reference_fps: int + sample_rate: int + name: str + frame_triggers: list[int] + pcm: array.array + + +@dataclass +class SppmNote: + metadata: dict + palette: list[tuple[int, int, int, int]] + # Each frame stores four validated RLE byte strings, A through D. + frames: list[tuple[memoryview, ...]] + audio_tracks: list[AudioTrack] + signature: dict + + +def _read_input(source: Input) -> bytes: + if isinstance(source, (bytes, bytearray, memoryview)): + data = bytes(source) + else: + data = Path(source).read_bytes() + if len(data) < HEADER_SIZE or len(data) > MAX_FILE_SIZE + PUBLIC_FOOTER_SIZE + 36: + raise SppmError("file size outside supported limits") + return data + + +class _Reader: + def __init__(self, data: bytes | memoryview, end: Optional[int] = None): + self.data = memoryview(data) + self.end = len(self.data) if end is None else end + self.at = 0 + + def take(self, size: int) -> memoryview: + if size < 0 or size > self.end - self.at: + raise SppmError("truncated payload") + start = self.at + self.at += size + return self.data[start:self.at] + + def u8(self) -> int: + return self.take(1)[0] + + def u16(self) -> int: + return struct.unpack(" int: + return struct.unpack(" int: + return struct.unpack(" str: + size = self.u16() + if size > maximum: + raise SppmError("metadata string exceeds limit") + raw = bytes(self.take(size)) + if b"\0" in raw: + raise SppmError("metadata contains NUL") + try: + return raw.decode("utf-8", "strict") + except UnicodeDecodeError as exc: + raise SppmError("metadata is not valid UTF-8") from exc + + def id(self, optional: bool = False) -> str: + value = bytes(self.take(16)).decode("ascii", "strict") + if optional and value == "0" * 16: + return "" + if len(value) != 16 or any(c not in "0123456789abcdefABCDEF" for c in value) or value == "0" * 16: + raise SppmError("invalid lineage ID") + return value.upper() + + +def _validate_rle(encoded: memoryview) -> None: + src = 0 + output = 0 + while src < len(encoded): + command = encoded[src] + src += 1 + count = (command & 0x7F) + 1 + if output + count > PIXELS: + raise SppmError("RLE layer expands beyond canvas size") + if command & 0x80: + if src >= len(encoded): + raise SppmError("truncated RLE repeat packet") + src += 1 + else: + if src + count > len(encoded): + raise SppmError("truncated RLE literal packet") + src += count + output += count + if output != PIXELS: + raise SppmError("RLE layer has incorrect decoded size") + + +def _decode_layer(encoded: memoryview) -> bytes: + out = bytearray(PIXELS) + src = 0 + dst = 0 + while src < len(encoded): + command = encoded[src] + src += 1 + count = (command & 0x7F) + 1 + if command & 0x80: + value = encoded[src] + src += 1 + out[dst:dst + count] = bytes((value,)) * count + else: + out[dst:dst + count] = encoded[src:src + count] + src += count + dst += count + return bytes(out) + + +def _identity_fsid(key: bytes, tag: bytes) -> str: + if not any(key) or not any(tag): + return "" + digest = hashlib.sha256(b"FlipnoteNX-public-fsid-v2\0" + key + tag).digest() + return digest[:8].hex().upper() + + +def _verify_public_signature(body: bytes, footer: memoryview) -> tuple[dict, list[tuple[bytes, bytes]]]: + fr = _Reader(footer) + if bytes(fr.take(4)) != b"SPP3": + raise SppmError("invalid public-signature marker") + identities = [(bytes(fr.take(32)), bytes(fr.take(16))) for _ in range(3)] + signature = bytes(fr.take(64)) + if fr.at != fr.end or not any(identities[2][0]) or not any(identities[2][1]): + raise SppmError("invalid public identity footer") + digest = hashlib.sha256( + b"FlipnoteNX-SPPM-public-signature-v3\0" + body + b"".join(k + tag for k, tag in identities) + ).digest() + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + Ed25519PublicKey.from_public_bytes(identities[2][0]).verify(signature, digest) + status = "verified" + except ImportError: + status = "present-unverified" + except Exception as exc: + raise SppmError("public signature verification failed") from exc + return {"status": status, "algorithm": "Ed25519"}, identities + + +def read_sppm(source: Input, *, legacy_hmac_key: Optional[bytes] = None) -> SppmNote: + """Parse and structurally validate an SPPM note. V3 signature verification uses optional `cryptography`.""" + data = _read_input(source) + if data[:4] != b"SPPM": + raise SppmError("bad file magic") + body_end = len(data) + signature = {"status": "unsigned"} + identities = None + public_at = len(data) - PUBLIC_FOOTER_SIZE + if public_at >= HEADER_SIZE and data[public_at:public_at + 4] == b"SPP3": + body_end = public_at + signature, identities = _verify_public_signature(data[:body_end], memoryview(data)[public_at:]) + elif len(data) >= HEADER_SIZE + 36 and data[-36:-32] == b"SPPS": + body_end = len(data) - 36 + signature = {"status": "present-unverified", "algorithm": "HMAC-SHA-256"} + if legacy_hmac_key is not None: + valid = hmac.compare_digest(hmac.new(legacy_hmac_key, data[:body_end], hashlib.sha256).digest(), data[-32:]) + if not valid: + raise SppmError("legacy HMAC verification failed") + signature["status"] = "verified" + + body = memoryview(data)[:body_end] + r = _Reader(body) + r.take(4) + version, header_size = r.u16(), r.u16() + total_size, expected_crc = r.u64(), r.u32() + width, height, layer_count, palette_count, fps, flags = r.u16(), r.u16(), r.u8(), r.u16(), r.u8(), r.u8() + frame_count, audio_count, reserved = r.u32(), r.u8(), r.u16() + if (version not in (1, 2, 3) or header_size != HEADER_SIZE or total_size != len(body) or + width != WIDTH or height != HEIGHT or layer_count != 4 or palette_count != 256 or + not 1 <= fps <= 30 or not 1 <= frame_count <= 9999 or audio_count > 4 or reserved != 0 or + (flags & ~(3 if version == 1 else 15))): + raise SppmError("unsupported SPPM header") + if zlib.crc32(body[HEADER_SIZE:]) & 0xFFFFFFFF != expected_crc: + raise SppmError("payload CRC mismatch") + + palbytes = r.take(256 * 4) + palette = [] + for i in range(256): + rgba = tuple(palbytes[i * 4:i * 4 + 4]) + rr, gg, bb, aa = rgba + if aa and (rr, gg, bb) == (26, 40, 72): rgba = (132, 66, 183, aa) + elif aa and (rr, gg, bb) == (92, 224, 36): rgba = (239, 68, 133, aa) + palette.append(rgba) + meta = { + "title": r.string(256), "description": r.string(4096), "authorName": r.string(128), + "originalAuthorName": r.string(128), "lastEditorName": r.string(128), + "authorFsid": r.id(), "originalAuthorFsid": r.id(), "lastEditorFsid": r.id(), + "currentId": r.id(), "rootId": r.id(), "parentId": r.id(True), + "createdUnixSeconds": r.u64(), "modifiedUnixSeconds": r.u64(), "coverFrame": r.u32(), + "width": width, "height": height, "fps": fps, "locked": bool(flags & 1), + "allowSpinoffs": bool(flags & 2), "bgmFollowsSpeed": version == 1 or bool(flags & 4), + "loopPlayback": True if version == 1 else not bool(flags & 8), + "frameCount": frame_count, "audioTrackCount": audio_count, "formatVersion": version, + "signature": signature["status"], + } + if meta["coverFrame"] >= frame_count: + raise SppmError("cover frame outside animation") + if identities: + meta["identities"] = [{"publicKeyHex": key.hex(), "deviceTagHex": tag.hex()} for key, tag in identities] + for field, identity_index in (("authorFsid", 0), ("originalAuthorFsid", 1), ("lastEditorFsid", 2)): + key, tag = identities[identity_index] + derived = _identity_fsid(key, tag) + if derived and meta[field] != derived: + raise SppmError(f"{field} does not match signed public identity") + + frames = [] + for fi in range(frame_count): + layers = [] + for li in range(4): + codec, length, crc = r.u8(), r.u32(), r.u32() + if codec != 1 or length < 1 or length > 460800: + raise SppmError("unsupported or oversized layer") + encoded = r.take(length) + if zlib.crc32(encoded) & 0xFFFFFFFF != crc: + raise SppmError(f"layer {li} CRC mismatch in frame {fi}") + _validate_rle(encoded) + layers.append(encoded) + frames.append(tuple(layers)) + + tracks = [] + total_samples = 0 + for ti in range(audio_count): + index, channels = r.u8(), r.u8() + reference_fps = r.u8() if version >= 2 else 0 + sample_rate, name, cue_count = r.u32(), r.string(64), r.u16() + if index != ti or channels not in (1, 2) or not 8000 <= sample_rate <= 48000 or cue_count > frame_count or (ti == 0 and cue_count): + raise SppmError("invalid audio metadata") + cues = [r.u16() for _ in range(cue_count)] + if any(c >= frame_count for c in cues) or any(a >= b for a, b in zip(cues, cues[1:])): + raise SppmError("invalid sound-effect frame cues") + sample_count, pcm_crc = r.u32(), r.u32() + total_samples += sample_count + if total_samples > MAX_AUDIO_SAMPLES or sample_count % channels: + raise SppmError("audio sample count outside supported limits") + raw_pcm = bytes(r.take(sample_count * 2)) + if zlib.crc32(raw_pcm) & 0xFFFFFFFF != pcm_crc: + raise SppmError("audio PCM CRC mismatch") + pcm = array.array("h") + pcm.frombytes(raw_pcm) + if struct.pack("=H", 1) != b"\x01\x00": + pcm.byteswap() + tracks.append(AudioTrack(index, channels, fps if ti == 0 and version == 1 else reference_fps, + sample_rate, name, cues, pcm)) + if r.at != body_end: + raise SppmError("unexpected trailing bytes") + return SppmNote(meta, palette, frames, tracks, signature) + + +def get_sppm_metadata(source: Input, **kwargs) -> dict: + """Return the note's metadata dictionary (including signature status).""" + return read_sppm(source, **kwargs).metadata + + +def render_frame(note: SppmNote, frame_index: int, *, background=(255, 255, 255, 255)) -> Image.Image: + """Composite one animation frame to a 640x360 RGBA Pillow image.""" + if not 0 <= frame_index < len(note.frames): + raise IndexError("frame index outside note") + layers = [_decode_layer(layer) for layer in note.frames[frame_index]] + rgba = bytearray(PIXELS * 4) + br, bg, bb, ba = background + for p in range(PIXELS): + rr, gg, bl, alpha = br, bg, bb, ba + # Stored A-D from front to back: compose from D up through A. + for layer_index in (3, 2, 1, 0): + ink = layers[layer_index][p] + if ink == 0: + continue + sr, sg, sb, sa = note.palette[ink] + if sa == 0: + continue + source_weight = sa * 255 + behind_weight = alpha * (255 - sa) + total = source_weight + behind_weight + out_a = (total + 127) // 255 + if out_a: + rr = (sr * source_weight + rr * behind_weight + total // 2) // total + gg = (sg * source_weight + gg * behind_weight + total // 2) // total + bl = (sb * source_weight + bl * behind_weight + total // 2) // total + alpha = out_a + at = p * 4 + rgba[at:at + 4] = bytes((rr, gg, bl, alpha)) + return Image.frombytes("RGBA", (WIDTH, HEIGHT), bytes(rgba)) + + +def get_sppm_thumbnail(source: Input, output: Optional[Union[str, os.PathLike]] = None, + *, size=(320, 180), background=(255, 255, 255, 255), **kwargs) -> Image.Image: + """Render the designated cover frame as a solid-background thumbnail.""" + if len(size) != 2 or not all(isinstance(v, int) and 1 <= v <= 4096 for v in size): + raise ValueError("thumbnail size values must be in 1..4096") + note = read_sppm(source, **kwargs) + image = render_frame(note, note.metadata["coverFrame"], background=background) + if size != (WIDTH, HEIGHT): + image = image.resize(size, Image.Resampling.NEAREST) + if output is not None: + image.save(output, format="PNG") + return image + + +def _source_stereo(track: AudioTrack) -> np.ndarray: + source = np.frombuffer(track.pcm, dtype=np.int16).astype(np.float32).reshape(-1, track.channels) / 32768.0 + if track.channels == 1: + return np.repeat(source, 2, axis=1) + return source + + +def _mix_wav(note: SppmNote, wav_path: str, sample_rate: int = 48000, chunk_seconds: int = 1) -> None: + total_frames = round(len(note.frames) * sample_rate / note.metadata["fps"]) + fps = note.metadata["fps"] + sources = [(track, _source_stereo(track)) for track in note.audio_tracks if len(track.pcm)] + with wave.open(wav_path, "wb") as output: + output.setnchannels(2) + output.setsampwidth(2) + output.setframerate(sample_rate) + for base in range(0, total_frames, sample_rate * chunk_seconds): + count = min(sample_rate * chunk_seconds, total_frames - base) + mix = np.zeros((count, 2), dtype=np.float32) + times = (base + np.arange(count, dtype=np.float64)) / sample_rate + for track, source in sources: + if track.index == 0: + rate = track.sample_rate * (fps / (track.reference_fps or fps) if note.metadata["bgmFollowsSpeed"] else 1.0) + positions = times * rate + valid = np.ones(count, dtype=bool) if note.metadata["loopPlayback"] else positions < len(source) + if note.metadata["loopPlayback"]: + positions = np.mod(positions, len(source)) + indexes = np.minimum(positions.astype(np.int64), max(0, len(source) - 1)) + next_indexes = np.minimum(indexes + 1, len(source) - 1) + fraction = (positions - indexes).astype(np.float32)[:, None] + sampled = source[indexes] * (1.0 - fraction) + source[next_indexes] * fraction + mix[valid] += sampled[valid] + else: + source_len = len(source) + for cue in track.frame_triggers: + start = round(cue * sample_rate / fps) + duration = int(np.ceil(source_len * sample_rate / track.sample_rate)) + out_start = max(base, start) + out_end = min(base + count, start + duration) + if out_end <= out_start: + continue + dst = np.arange(out_start, out_end, dtype=np.float64) + positions = (dst - start) * track.sample_rate / sample_rate + indexes = np.minimum(positions.astype(np.int64), source_len - 1) + next_indexes = np.minimum(indexes + 1, source_len - 1) + fraction = (positions - indexes).astype(np.float32)[:, None] + sampled = source[indexes] * (1.0 - fraction) + source[next_indexes] * fraction + mix[out_start - base:out_end - base] += sampled + pcm = np.clip(mix, -1.0, 1.0) + output.writeframesraw((pcm * 32767.0).astype(" Path: + """Convert a note to H.264/AAC MP4. Requires Pillow, NumPy, and ffmpeg on PATH.""" + note = read_sppm(source, legacy_hmac_key=legacy_hmac_key) + ffmpeg_path = shutil.which(ffmpeg) if not os.path.isfile(ffmpeg) else ffmpeg + if not ffmpeg_path: + raise FileNotFoundError("ffmpeg was not found; install ffmpeg or pass ffmpeg=... ") + target = Path(output) + if target.suffix.lower() != ".mp4": + raise ValueError("output filename must end in .mp4") + target.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="sppm-mp4-") as temp_dir: + wav_path = os.path.join(temp_dir, "audio.wav") + has_audio = include_audio and any(track.pcm for track in note.audio_tracks) + if has_audio: + _mix_wav(note, wav_path) + command = [ffmpeg_path, "-hide_banner", "-loglevel", "error", "-y", + "-f", "rawvideo", "-pix_fmt", "rgba", "-s:v", f"{WIDTH}x{HEIGHT}", + "-r", str(note.metadata["fps"]), "-i", "-"] + if has_audio: + command += ["-i", wav_path] + command += ["-map", "0:v:0"] + if has_audio: + command += ["-map", "1:a:0", "-c:a", "aac", "-b:a", "192k"] + command += ["-c:v", "libx264", "-preset", preset, "-crf", str(video_crf), + "-pix_fmt", "yuv420p", "-r", str(note.metadata["fps"]), + "-movflags", "+faststart", str(target)] + process = subprocess.Popen(command, stdin=subprocess.PIPE, stderr=subprocess.PIPE) + assert process.stdin is not None + try: + for index in range(len(note.frames)): + process.stdin.write(render_frame(note, index).tobytes()) + process.stdin.close() + stderr = process.stderr.read() if process.stderr else b"" + if process.stderr: + process.stderr.close() + status = process.wait() + except BaseException: + process.kill() + process.wait() + raise + if status: + target.unlink(missing_ok=True) + raise RuntimeError(f"ffmpeg conversion failed ({status}): {stderr.decode('utf-8', 'replace').strip()}") + return target + + +__all__ = ["AudioTrack", "SppmError", "SppmNote", "get_sppm_metadata", "get_sppm_thumbnail", + "read_sppm", "render_frame", "sppm_to_mp4"] diff --git a/sppm-python/test_sppm_tools.py b/sppm-python/test_sppm_tools.py new file mode 100644 index 0000000..3aca121 --- /dev/null +++ b/sppm-python/test_sppm_tools.py @@ -0,0 +1,80 @@ +import os +import shutil +import struct +import tempfile +import unittest +import zlib +from pathlib import Path + +from sppm_tools import get_sppm_metadata, get_sppm_thumbnail, read_sppm, sppm_to_mp4 + + +def fixture() -> bytes: + payload = bytearray() + palette = bytearray(256 * 4) + palette[4:8] = bytes((255, 0, 0, 255)) + payload.extend(palette) + + def text(value: str) -> None: + encoded = value.encode("utf-8") + payload.extend(struct.pack("