Skip to content

Instantly share code, notes, and snippets.

@jaames
Last active July 6, 2022 20:33
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jaames/7dfe0fcd24954e70b180e648be955ae8 to your computer and use it in GitHub Desktop.
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)
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