118 lines
5.8 KiB
JavaScript
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);
|
