Add files via upload
This commit is contained in:
35 files changed
+14482
No files matched your search
@@ -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.
|
||||
@@ -0,0 +1,352 @@
|
||||
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 || (version < 3 ? reserved !== 0 : reserved > 8) || ((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,
|
||||
speed: version >= 3 ? reserved : 0, nominalFps: fps,
|
||||
fps: version >= 3 && reserved >= 1 ? [0, 0.4985, 1, 2, 4, 5.9972, 11.986, 19.9459, 29.9275][reserved] : 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(), storedReference = 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);
|
||||
const referenceSpeed = version >= 3 && reserved !== 0 && ti === 0 ? storedReference : 0;
|
||||
const referenceFps = version >= 3 && reserved !== 0 ? 0 : ti === 0 && version === 1 ? fps : storedReference;
|
||||
if (version >= 3 && reserved !== 0 && (ti === 0 ? storedReference > 8 : storedReference !== 0)) fail("invalid audio speed metadata");
|
||||
audioTracks.push({ index, name, channels, referenceFps, referenceSpeed, 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.referenceSpeed ? [0, 0.4985, 1, 2, 4, 5.9972, 11.986, 19.9459, 29.9275][track.referenceSpeed] : (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 });
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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(version = 2, speed = 0) {
|
||||
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(version); u16(36); u64(0); u32(0); u16(640); u16(360); u8(4); u16(256); u8(12); u8(2); u32(1); u8(0); u16(speed);
|
||||
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/);
|
||||
});
|
||||
|
||||
test("reads DSi speed indices using the measured frame rates", async () => {
|
||||
const bytes = fixture(3, 1);
|
||||
const note = await readSppm(bytes);
|
||||
assert.equal(note.metadata.speed, 1);
|
||||
assert.equal(note.metadata.fps, 0.4985);
|
||||
assert.equal(note.metadata.nominalFps, 12);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
Pillow>=10.0
|
||||
numpy>=1.24
|
||||
# Optional: install this to verify current SPP3 Ed25519 signatures.
|
||||
# cryptography>=41.0
|
||||
@@ -0,0 +1,450 @@
|
||||
"""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
|
||||
reference_speed: int = 0
|
||||
|
||||
|
||||
@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 if version < 3 else reserved > 8) 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,
|
||||
"speed": reserved if version >= 3 else 0, "nominalFps": fps,
|
||||
"fps": (0.4985, 1.0, 2.0, 4.0, 5.9972, 11.986, 19.9459, 29.9275)[reserved - 1] if version >= 3 and reserved else 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()
|
||||
reference_speed = reference_fps if version >= 3 and reserved != 0 and ti == 0 else 0
|
||||
if version >= 3 and reserved != 0 and (reference_speed > 8 or (ti > 0 and reference_fps != 0)):
|
||||
raise SppmError("invalid audio speed metadata")
|
||||
legacy_reference_fps = (fps if ti == 0 else reference_fps) if version < 3 or reserved == 0 else 0
|
||||
tracks.append(AudioTrack(index, channels, legacy_reference_fps,
|
||||
sample_rate, name, cues, pcm, reference_speed))
|
||||
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:
|
||||
reference_fps = ((0.4985, 1.0, 2.0, 4.0, 5.9972, 11.986, 19.9459, 29.9275)[track.reference_speed - 1]
|
||||
if track.reference_speed else (track.reference_fps or fps))
|
||||
rate = track.sample_rate * (fps / reference_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"]
|
||||
@@ -0,0 +1,86 @@
|
||||
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(version: int = 2, speed: int = 0) -> 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, (6 if version >= 3 and speed else 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", version, 36, 36 + len(payload),
|
||||
zlib.crc32(payload) & 0xffffffff, 640, 360, 4, 256,
|
||||
12, 2, 1, 1, speed))
|
||||
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)
|
||||
|
||||
def test_reads_dsi_speed_indices_using_measured_rates(self):
|
||||
note = read_sppm(fixture(version=3, speed=1))
|
||||
self.assertEqual(note.metadata["speed"], 1)
|
||||
self.assertEqual(note.metadata["fps"], 0.4985)
|
||||
self.assertEqual(note.audio_tracks[0].reference_speed, 6)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user
