(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);