Files
flipnote-editor/assets/ppm-import.js
T
2026-10-29 11:56:15 +00:00

118 lines
5.8 KiB
JavaScript

(function (global) {
'use strict';
const WIDTH = 256;
const HEIGHT = 192;
const MAX_BYTES = 1024 * 1024;
const MAX_PAGES = 999;
const SAMPLE_RATE = 32768;
function checkFile(buffer) {
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 0x6C0 || buffer.byteLength > MAX_BYTES) {
throw new Error('Choose a complete Flipnote Studio PPM, no larger than 1,024 KB.');
}
const view = new DataView(buffer);
if (view.getUint32(0, false) !== 0x50415241) throw new Error('That file is not a Flipnote Studio PPM.');
const pages = view.getUint16(12, true) + 1;
const animationEnd = 0x6A0 + view.getUint32(4, true);
if (pages > MAX_PAGES || animationEnd < 0x6A8 || animationEnd + pages > buffer.byteLength) {
throw new Error('The PPM page data is incomplete or exceeds 999 pages.');
}
const tableBytes = view.getUint16(0x6A0, true);
const firstFrame = 0x6A8 + tableBytes;
if (tableBytes < pages * 4 || firstFrame >= animationEnd) throw new Error('The PPM page table is invalid.');
for (let index = 0; index < pages; index++) {
if (firstFrame + view.getUint32(0x6A8 + index * 4, true) >= animationEnd) {
throw new Error('A PPM page points outside the animation data.');
}
}
const soundStart = Math.ceil((animationEnd + pages) / 4) * 4;
if (soundStart + 32 + view.getUint32(8, true) > buffer.byteLength) {
throw new Error('The PPM audio data is incomplete.');
}
let audioBytes = 0;
for (let index = 0; index < 4; index++) audioBytes += view.getUint32(soundStart + index * 4, true);
if (audioBytes > view.getUint32(8, true)) throw new Error('The PPM audio track lengths are invalid.');
return pages;
}
function base64(bytes) {
let text = '';
for (let offset = 0; offset < bytes.length; offset += 8192) {
text += String.fromCharCode(...bytes.subarray(offset, offset + 8192));
}
return btoa(text);
}
function layerData(pixels) {
const bytes = new Uint8Array(WIDTH * HEIGHT / 8);
for (let index = 0; index < pixels.length; index++) {
if (pixels[index]) bytes[index >> 3] |= 1 << (index & 7);
}
return base64(bytes);
}
function pcmWav(pcm) {
const bytes = new Uint8Array(44 + pcm.length * 2);
const view = new DataView(bytes.buffer);
const text = (offset, value) => { for (let i = 0; i < value.length; i++) bytes[offset + i] = value.charCodeAt(i); };
text(0, 'RIFF'); view.setUint32(4, bytes.length - 8, true); text(8, 'WAVE');
text(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
view.setUint16(22, 1, true); view.setUint32(24, SAMPLE_RATE, true);
view.setUint32(28, SAMPLE_RATE * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
text(36, 'data'); view.setUint32(40, pcm.length * 2, true);
for (let i = 0; i < pcm.length; i++) view.setInt16(44 + i * 2, pcm[i], true);
return bytes;
}
function decode(buffer, filename = 'Flipnote.ppm') {
checkFile(buffer);
const parser = new global.flipnote.PpmParser(buffer);
const flags = parser.decodeSoundFlags();
const ink = index => index === 2 ? 'red' : index === 3 ? 'blue' : 'black';
const frames = [];
for (let index = 0; index < parser.frameCount; index++) {
const layers = parser.decodeFrame(index);
const palette = parser.getFramePaletteIndices(index);
frames.push({
layer1: layerData(layers[0]), layer2: layerData(layers[1]),
colours: [ink(palette[1]), ink(palette[2])],
paper: palette[0] === 1 ? 'black' : 'white',
sounds: {sound1: flags[index][0], sound2: flags[index][1], sound3: flags[index][2]},
});
}
const audio = {};
const tracks = [['music', global.flipnote.FlipnoteAudioTrack.BGM],
['sound1', global.flipnote.FlipnoteAudioTrack.SE1],
['sound2', global.flipnote.FlipnoteAudioTrack.SE2],
['sound3', global.flipnote.FlipnoteAudioTrack.SE3]];
for (const [key, track] of tracks) {
if (!parser.hasAudioTrack(track)) continue;
const pcm = parser.getAudioTrackPcm(track, SAMPLE_RATE);
if (!pcm.length) continue;
audio[key] = {data: base64(pcmWav(pcm)), mime:'audio/wav', duration:pcm.length / SAMPLE_RATE,
reference_fps:parser.framerate, source_name:filename, source_kind:'ppm_import'};
}
return {
project: {
title:String(filename).replace(/\.ppm$/i, '').slice(0, 32) || 'Imported Flipnote',
speed_level:parser.frameSpeed, loop:parser.meta.loop,
thumbnail_frame:Math.max(0, Math.min(frames.length - 1, parser.thumbFrameIndex)),
layer_visibility:[parser.layerVisibility[1], parser.layerVisibility[2]], frames, audio,
},
source:{filename, original_author_name:parser.meta.current.username},
};
}
async function read(file) {
if (!file || !/\.ppm$/i.test(file.name || '')) throw new Error('Choose a Flipnote Studio .PPM file.');
if (file.size > MAX_BYTES) throw new Error('That PPM is larger than the 1,024 KB limit.');
try { return decode(await file.arrayBuffer(), file.name); }
catch (error) { throw new Error('Could not open this PPM: ' + (error.message || 'invalid file data')); }
}
const api = Object.freeze({read, decode});
global.FlipnotePpmImport = api;
if (typeof module === 'object' && module.exports) module.exports = api;
})(typeof window === 'object' ? window : globalThis);