Last active
July 6, 2022 20:33
-
-
Save jaames/7dfe0fcd24954e70b180e648be955ae8 to your computer and use it in GitHub Desktop.
Basic Playdate .PDV video format parser in Typescript (doesn't handle frame type 3 yet)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { unzlibSync } from 'fflate'; | |
function assert(condition: boolean, errMsg: string = 'Assert failed'): asserts condition { | |
if (!condition) { | |
console.trace(errMsg); | |
throw new Error(errMsg); | |
} | |
} | |
function readChars(data: DataView, ptr: number, size?: number) { | |
let result = ''; | |
if (size !== undefined) { | |
for (let i = 0; i < size; i++) { | |
const byte = data.getUint8(ptr + i); | |
if (byte === 0) | |
break; | |
result += String.fromCharCode(byte); | |
} | |
} | |
else { | |
let i = 0; | |
while(true) { | |
const byte = data.getUint8(ptr + i); | |
if (byte === 0) | |
break; | |
result += String.fromCharCode(byte); | |
i += 1; | |
} | |
} | |
return result; | |
} | |
const align = (x: number, size: number) => x + (size - x % size) % size; | |
export enum PdvOffsetType { | |
EndOfFile = 0, | |
IFrame = 1, | |
PFrame = 2, // Dave from Panic calls these delta frames? | |
BFrame = 3 // bi-directional frame (TODO) | |
}; | |
export class PdVideoParser { | |
buffer: ArrayBuffer; | |
bufferSize: number; | |
// data fields | |
ident: string; | |
numFrames: number; | |
frameRate: number; | |
duration: number; | |
width: number; | |
height: number; | |
// offset table data | |
private baseOffset: number; | |
private tableTypes: PdvOffsetType[]; | |
private tableOffsets: number[]; | |
// prev frame info, for merging into p-frames | |
private prevFrameIndex: number = -Infinity; | |
private prevFrameBuffer: Uint8Array; | |
private currFrameBuffer: Uint8Array; | |
private frameBufferSize: number; | |
constructor(buffer: ArrayBuffer) { | |
this.buffer = buffer; | |
this.bufferSize = buffer.byteLength; | |
// parse header | |
const view = new DataView(buffer); | |
const ident = readChars(view, 0, 16); | |
assert(ident === 'Playdate VID', `File ident ${ ident } not recognized`); | |
const numFrames = view.getInt16(16, true); | |
const reserved = view.getInt16(18, true); | |
const frameRate = view.getFloat32(20, true); | |
const width = view.getInt16(24, true); | |
const height = view.getInt16(26, true); | |
assert(numFrames > 0); | |
assert(frameRate > 0); | |
assert(reserved === 0); // always hardcoded to 0 | |
assert(width === 400 && height === 240, 'Unknown video size, should be hardcoded to 400x240'); | |
this.ident = ident; | |
this.numFrames = numFrames; | |
this.frameRate = frameRate; | |
this.width = width; | |
this.height = height | |
this.duration = numFrames / frameRate; | |
this.tableTypes = new Array<PdvOffsetType>(numFrames); | |
this.tableOffsets = new Array<number>(numFrames); | |
this.baseOffset = 28 + (numFrames + 1) * 4; | |
// table time | |
for (let i = 0; i < numFrames + 1; i++) { | |
const v = view.getUint32(28 + i * 4, true); | |
const type = v & 0x3; | |
const offset = (v >> 2) + this.baseOffset; | |
console.log(v, offset, this.bufferSize) | |
assert(offset <= this.bufferSize, 'Frame offset is out of bounds'); | |
this.tableTypes[i] = type; | |
this.tableOffsets[i] = offset; | |
} | |
assert(this.tableTypes[numFrames] === 0, 'Incorrect end-of-file offset type'); | |
assert(this.tableOffsets[numFrames] === this.bufferSize, 'Incorrect end-of-file offset'); | |
// setup file buffers | |
this.frameBufferSize = (align(this.width, 8) / 8) * this.height; | |
this.prevFrameBuffer = new Uint8Array(this.frameBufferSize); | |
this.currFrameBuffer = new Uint8Array(this.frameBufferSize); | |
} | |
getSize() { | |
return [this.width, this.height]; | |
} | |
getFrameCount() { | |
return this.numFrames; | |
} | |
getFrameRate() { | |
return this.frameRate; | |
} | |
isKeyFrame(frameIndex: number) { | |
return this.tableTypes[frameIndex] === PdvOffsetType.IFrame; | |
} | |
getFrameBuffer(frameIndex: number) { | |
const isKeyFrame = this.isKeyFrame(frameIndex); | |
// decode previous frames until we hit a keyframe, if we haven't already decoded the previous frame | |
if (this.prevFrameIndex !== frameIndex - 1 && frameIndex !== 0 && !isKeyFrame) | |
this.getFrameBuffer(frameIndex - 1); | |
// get frame data, and decompress | |
const currFrameBuffer = this.currFrameBuffer; | |
const prevFrameBuffer = this.prevFrameBuffer; | |
const size = this.frameBufferSize; | |
const start = this.tableOffsets[frameIndex]; | |
const end = this.tableOffsets[frameIndex + 1]; | |
const compressed = new Uint8Array(this.buffer, start, end - start); | |
unzlibSync(compressed, currFrameBuffer); | |
// TODO: bidirectional frame | |
// non-keyframes ("P" frames) just store the changes since the previous frame | |
// these can be resolved into a full picture by XORing every pixel between the two frames | |
// since the image is 1-bit, using the bitwise XOR operator on every byte on the image is the fastest way to do this | |
if (!isKeyFrame) { | |
for (let ptr = 0; ptr < size; ptr += 1) | |
currFrameBuffer[ptr] ^= prevFrameBuffer[ptr]; | |
} | |
// keep track of our progress | |
prevFrameBuffer.set(currFrameBuffer); | |
this.prevFrameIndex = frameIndex; | |
return currFrameBuffer; | |
} | |
/** | |
* Get an 8-bit pixel buffer for a given frame. Returns an Uint8Array where each element represents one pixel; `0x0` for black and `0x1` for white. | |
* This will automatically handle merging p-frames and out-of-order frame access for you. | |
*/ | |
getFramePixels(frameIndex: number, dst = new Uint8Array(this.width * this.height)) { | |
assert(dst.byteLength === this.width * this.height, `Pixel array length must be ${ this.width * this.height }`); | |
const src = this.getFrameBuffer(frameIndex); | |
const srcSize = src.byteLength; | |
let srcPtr = 0; | |
let dstPtr = 0; | |
while (srcPtr < srcSize) { | |
// each one-byte chunk contains 8 pixels | |
const chunk = src[srcPtr++]; | |
// unpack each bit of the chunk | |
for (let shift = 7; shift >= 0; shift--) | |
dst[dstPtr++] = (chunk >> shift) & 0x1; | |
} | |
return dst; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment