Add files via upload

This commit is contained in:
Rekushi authored and GitHub committed 2026-10-20 03:40:41 +01:00
1 parent aa11d6cb2b
commit 8f31bdd977
9 files changed
+990

No files matched your search

+21
View File
@@ -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.
+19
View File
@@ -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.
+347
View File
@@ -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 });
+10
View File
@@ -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"
}
+47
View File
@@ -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/);
});
+22
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
Pillow>=10.0
numpy>=1.24
# Optional: install this to verify current SPP3 Ed25519 signatures.
# cryptography>=41.0
+440
View File
@@ -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("<H", self.take(2))[0]
def u32(self) -> int:
return struct.unpack("<I", self.take(4))[0]
def u64(self) -> int:
return struct.unpack("<Q", self.take(8))[0]
def string(self, maximum: int) -> 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("<i2").tobytes())
def sppm_to_mp4(source: Input, output: Union[str, os.PathLike], *, ffmpeg: str = "ffmpeg",
include_audio: bool = True, video_crf: int = 18, preset: str = "medium",
legacy_hmac_key: Optional[bytes] = None) -> 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"]
+80
View File
@@ -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("<H", len(encoded)))
payload.extend(encoded)
for value in ("Python fixture", "", "Tester", "Tester", "Tester"):
text(value)
for value in ("1111111111111111", "1111111111111111", "1111111111111111",
"2222222222222222", "2222222222222222", "0000000000000000"):
payload.extend(value.encode("ascii"))
payload.extend(struct.pack("<QQI", 100, 100, 0))
zero_runs = bytearray()
for _ in range(1800):
zero_runs.extend((255, 0)) # 128 transparent pixels per packet
first_layer = bytes((0, 1)) + bytes(zero_runs[:3598]) + bytes((254, 0))
for layer in range(4):
encoded = first_layer if layer == 0 else bytes(zero_runs)
payload.extend(struct.pack("<BII", 1, len(encoded), zlib.crc32(encoded) & 0xffffffff))
payload.extend(encoded)
pcm = struct.pack("<800h", *([1200, -1200] * 400))
payload.extend(struct.pack("<BBB I", 0, 1, 12, 8000))
text("BGM")
payload.extend(struct.pack("<HII", 0, 800, zlib.crc32(pcm) & 0xffffffff))
payload.extend(pcm)
header = bytearray(b"SPPM")
header.extend(struct.pack("<HHQ I HH B H B B I B H", 2, 36, 36 + len(payload),
zlib.crc32(payload) & 0xffffffff, 640, 360, 4, 256,
12, 2, 1, 1, 0))
assert len(header) == 36
return bytes(header + payload)
class SppmToolTests(unittest.TestCase):
def test_metadata_thumbnail_and_layer_composite(self):
note = read_sppm(fixture())
self.assertEqual(get_sppm_metadata(fixture())["title"], "Python fixture")
self.assertEqual(note.metadata["frameCount"], 1)
self.assertEqual(note.audio_tracks[0].name, "BGM")
self.assertEqual(len(note.audio_tracks[0].pcm), 800)
image = get_sppm_thumbnail(fixture(), size=(640, 360))
self.assertEqual(image.getpixel((0, 0)), (255, 0, 0, 255))
self.assertEqual(image.getpixel((1, 0)), (255, 255, 255, 255))
@unittest.skipUnless(shutil.which("ffmpeg"), "ffmpeg not installed")
def test_mp4_conversion(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "fixture.mp4"
result = sppm_to_mp4(fixture(), output)
self.assertEqual(result, output)
self.assertGreater(output.stat().st_size, 500)
def test_rejects_corrupt_file(self):
broken = bytearray(fixture())
broken[80] ^= 1
with self.assertRaisesRegex(ValueError, "CRC"):
read_sppm(broken)
if __name__ == "__main__":
unittest.main()