Skip to content

Instantly share code, notes, and snippets.

@chrahunt
Created November 23, 2016 02:27
Show Gist options
  • Save chrahunt/72fa2c6873c3ca6daaa766a9e3d81cc1 to your computer and use it in GitHub Desktop.
Save chrahunt/72fa2c6873c3ca6daaa766a9e3d81cc1 to your computer and use it in GitHub Desktop.
Attempted whammy rewrite. Unusable.
/**
* Generates a WebM video from an array of WebP images by making all
* of them key frames.
* For references, see:
* * WebP container format: https://developers.google.com/speed/webp/docs/riff_container
* * VP8 spec: https://datatracker.ietf.org/doc/rfc6386/?include_text=1
* * EBML guide: https://matroska-org.github.io/libebml/specs.html
*
* To use in the browser, compile and pass to Blob constructor:
* blob = new Blob(video.compile(), { type: "video/webm" });
* in node, just pass to Buffer.from()
*/
const Buffer = require('buffer').Buffer;
const logger = require('./logger')('webp');
function assert(msg, test) {
if (!test) throw new Error(msg);
}
/**
* @param {Array.<??>} frames
* @param {bool} outputAsArray
* @returns {Uint8Array} result of webm compilation
*/
function toWebM(frames) {
let info = checkFrames(frames);
//max duration by cluster in milliseconds
const CLUSTER_MAX_DURATION = 30000;
let ebml_struct = [{ // EBML
"id": 0x1a45dfa3,
"data": [{ // EBMLVersion
"data": 1,
"id": 0x4286
}, { // EBMLReadVersion
"data": 1,
"id": 0x42f7
}, { // EBMLMaxIDLength
"data": 4,
"id": 0x42f2
}, { // EBMLMaxSizeLength
"data": 8,
"id": 0x42f3
}, { // DocType
"data": "webm",
"id": 0x4282
}, { // DocTypeVersion
"data": 2,
"id": 0x4287
}, { // DocTypeReadVersion
"data": 2,
"id": 0x4285
}]
}, { // Segment
"id": 0x18538067,
"data": [{ // Info
"id": 0x1549a966,
"data": [{ // TimecodeScale
"data": 1e6, // do things in millisecs (num of nanosecs for duration scale)
"id": 0x2ad7b1
}, { // MuxingApp
"data": "whammy",
"id": 0x4d80
}, { // WritingApp
"data": "whammy",
"id": 0x5741
}, { // Duration
"data": doubleToString(info.duration),
"id": 0x4489
}]
}, { // Tracks
"id": 0x1654ae6b,
"data": [{ // TrackEntry
"id": 0xae,
"data": [{ // TrackNumber
"data": 1,
"id": 0xd7
}, { // TrackUID
"data": 1,
"id": 0x63c5
}, { // FlagLacing
"data": 0,
"id": 0x9c
}, { // Language
"data": "und",
"id": 0x22b59c
}, { // CodecID
"data": "V_VP8",
"id": 0x86
}, { // CodecName
"data": "VP8",
"id": 0x258688
}, { // TrackType
"data": 1,
"id": 0x83
}, { // Video
"id": 0xe0,
"data": [{ // PixelWidth
"data": info.width,
"id": 0xb0
}, { // PixelHeight
"data": info.height,
"id": 0xba
}]
}]
}]
},
// cluster insertion point
]
}];
// Generate clusters (max duration)
var frameNumber = 0;
var clusterTimecode = 0;
while (frameNumber < frames.length) {
var clusterFrames = [];
var clusterDuration = 0;
do {
clusterFrames.push(frames[frameNumber]);
clusterDuration += frames[frameNumber].duration;
frameNumber++;
} while (frameNumber < frames.length && clusterDuration < CLUSTER_MAX_DURATION);
var clusterCounter = 0;
var cluster = { // Cluster
"id": 0x1f43b675,
"data": [{ // Timecode
"data": Math.round(clusterTimecode),
"id": 0xe7
}].concat(clusterFrames.map(function (webp) {
var block = makeSimpleBlock({
discardable: 0,
frame: webp.data,
invisible: 0,
keyframe: 1,
lacing: 0,
trackNum: 1,
timecode: Math.round(clusterCounter)
});
clusterCounter += webp.duration;
return {
data: block,
id: 0xa3
};
}))
};
// Add cluster to segment
ebml_struct[1].data.push(cluster);
clusterTimecode += clusterDuration;
}
let [_, arr] = generateEBML(ebml_struct);
let flattened = flattenBlobParts(arr);
return flattened;
}
/**
* Flatten an array of binary-likes into a Uint8Array,
* similar to the behavior of the Blob constructor.
*/
function flattenBlobParts(parts) {
let result = new Uint8Array();
for (let i = 0; i < parts.length; i++) {
let part = parts[i];
let buf;
if (typeof part == 'string') {
buf = strToBuffer(part);
} else if (part instanceof ArrayBuffer) {
buf = new Uint8Array(part);
} else if (part instanceof Uint8Array) {
buf = part;
} else if (part.buffer) {
buf = new Uint8Array(part.buffer);
}
result = concatBuffers(result, buf);
}
return result;
}
// sums the lengths of all the frames and gets the duration, woo
function checkFrames(frames) {
let width = frames[0].width,
height = frames[0].height,
duration = frames[0].duration;
for (let i = 1; i < frames.length; i++) {
let frame = frames[i];
assert(`The width of frame ${i} (${frame.width}) must be the same as the first frame (${width}).`, frame.width === width);
assert(`The height of frame ${i} (${frame.height}) must be the same as the first frame (${height}).`, frame.height === height);
assert(`Frame ${i} must have a sane duration (${frame.duration})`, 0 < frame.duration && frame.duration <= 0x7FFF);
duration += frame.duration;
}
return {
duration: duration,
width: width,
height: height
};
}
// https://www.matroska.org/technical/specs/index.html#simpleblock_structure
function makeSimpleBlock(data) {
var flags = 0;
if (data.keyframe) flags |= 128;
if (data.invisible) flags |= 8;
if (data.lacing) flags |= (data.lacing << 1);
if (data.discardable) flags |= 1;
assert(`Track number ${data.trackNum} must be <= 127`, data.trackNum <= 127);
let header = Uint8Array.from([
encodeEbmlValue(data.trackNum),
data.timecode >> 8,
data.timecode & 0xff,
flags
]);
return concatBuffers(header, data.frame);
}
function concatBuffers(buf1, buf2) {
let tmp = new Uint8Array(buf1.byteLength + buf2.byteLength);
tmp.set(new Uint8Array(buf1), 0);
tmp.set(new Uint8Array(buf2), buf1.byteLength);
return tmp.buffer;
}
// Takes a number and splits it into big-endian representation.
function numToBuffer(num) {
var parts = [];
while (num > 0) {
parts.push(num & 0xff)
num = num >> 8
}
return new Uint8Array(parts.reverse());
}
// Takes a string and converts to a typed array.
// How does this handle the fact that charCodeAt
// returns the UTF-16 value and the Uint8Array overflows?
// it doesn't, this only handles ascii strings.
function strToBuffer(str) {
var arr = new Uint8Array(str.length);
for (var i = 0; i < str.length; i++) {
arr[i] = str.charCodeAt(i);
}
return arr;
}
// Takes bitstring, 0110000110, pad it, and turn it into a typed array.
function bitsToBuffer(bits) {
let data = [];
let pad;
if (bits.length % 8) {
// if bits.length is not divisible by 8
// create a string of 0s as a pad.
pad = '0'.repeat(Math.max(8 - (bits.length % 8), 0));
} else {
pad = '';
}
// prefix the pad to the original bit string.
bits = pad + bits;
for (let i = 0; i < bits.length; i += 8) {
// get the i-th byte as a number
let val = parseInt(bits.substr(i, 8), 2);
data.push(val);
}
return new Uint8Array(data);
}
// here's a little utility function that acts as a utility for other functions
// basically, the only purpose is for encoding "Duration", which is encoded as
// a double (considerably more difficult to encode than an integer)
function doubleToString(num) {
return [].slice.call(
new Uint8Array(
(
new Float64Array([num]) //create a float64 array
).buffer) //extract the array buffer
, 0) // convert the Uint8Array into a regular array
.map(function (e) { //since it's a regular array, we can now use map
return String.fromCharCode(e) // encode all the bytes individually
})
.reverse() //correct the byte endianness (assume it's little endian for now)
.join('') // join the bytes in holy matrimony as a string
}
/**
* Encode data in UTF-8 like format. Used for IDs (already implicit in the id
* value used) and size.
* spec: http://matroska-org.github.io/libebml/specs.html
* @param {number} val integer value to be encoded.
* @returns {Uint8Array}
*/
function encodeEbmlValue(val) {
let bits_needed = Math.ceil(Math.log(val) / Math.log(2));
let result = val;
if (val < Math.pow(2, 7) - 2) {
result |= 0x80;
} else if (val < Math.pow(2, 14) - 2) {
result |= 0x4000;
} else if (val < Math.pow(2, 21) - 2) {
result |= 0x200000;
} else if (val < Math.pow(2, 28) - 2) {
result |= 0x10000000;
} else if (val < Math.pow(2, 35) - 2) {
result |= 0x800000000;
} else if (val < Math.pow(2, 42) - 2) {
result |= 0x20000000000;
} else if (val < Math.pow(2, 49) - 2) {
result |= 0x2000000000000;
} else if (val < Math.pow(2, 56) - 2) {
result |= 0x100000000000000;
} else {
throw new Error(`${val} is too large to be a valid id/size`);
}
return numToBuffer(result);
}
/**
* Generate EBML from array of data objects.
* @param {Array} struct
* @returns {Array} array of bloblikes
*/
function generateEBML(struct) {
let ebml = [];
let total_size = 0;
for (let i = 0; i < struct.length; i++) {
var data = struct[i].data;
let num_bytes;
if (Array.isArray(data)) {
// Recurse into nested structure.
[num_bytes, data] = generateEBML(data);
} else if (typeof data == 'number') {
data = bitsToBuffer(data.toString(2));
num_bytes = data.length;
} else if (typeof data == 'string') {
data = strToBuffer(data);
num_bytes = data.length;
} else {
num_bytes = data.size || data.byteLength || data.length;
}
let id = numToBuffer(struct[i].id);
ebml.push(id);
let encoded_size = encodeEbmlValue(num_bytes);
ebml.push(encoded_size);
if (Array.isArray(data)) {
ebml.push(...data);
} else {
ebml.push(data);
}
total_size += num_bytes + id.length + encoded_size.length;
}
return [total_size, ebml];
}
// Flatten array and typed arrays.
function flatten(array, result = []) {
for (let i = 0; i < array.length; i++) {
const val = array[i];
if (typeof val == 'object' && val.length) {
flatten(val, result);
} else {
result.push(val);
}
}
return result;
}
/**
* Read FourCC and return string.
* @param {DataView} view the view referencing the buffer.
* @param {number} offset the offset from which to read the value.
* @returns {string} the extracted string
*/
function readFourCC(view, offset = 0) {
return String.fromCharCode(view.getUint8(offset),
view.getUint8(offset + 1),
view.getUint8(offset + 2),
view.getUint8(offset + 3));
}
let chunk_header_size = 8;
/**
* @param {ArrayBuffer} buffer
* @param {number} offset
* @returns {object}
*/
function parseChunk(buffer, offset = 0) {
let view = new DataView(buffer, offset, chunk_header_size);
let chunk = {
FourCC: readFourCC(view),
Size: view.getUint32(4, true)
};
chunk.Payload = buffer.slice(offset + 8, offset + 8 + chunk.Size);
// Odd-sized chunks have a 0 padding.
let next = (chunk.Size % 2 == 0) ? offset + 8 + chunk.Size
: offset + 8 + chunk.Size + 1;
return [chunk, next];
}
/**
* Parse WebP into sequence of chunks.
*
* WebP format spec:
* https://developers.google.com/speed/webp/docs/riff_container?csw=1
* RIFF
* size
* WEBP
* data
* @param {ArrayBuffer} buffer
* @returns {Array.<Chunk>}
*/
function parseWebP(buffer) {
let view = new DataView(buffer);
let offset = 0;
let label = readFourCC(view, offset);
offset += 4;
assert(`${label} should equal RIFF`, label === 'RIFF');
let size = view.getUint32(4, true);
offset += 4;
label = readFourCC(view, 8);
let read = 4;
offset += 4;
assert(`${label} should equal WEBP`, label === 'WEBP');
let chunks = [];
while (offset < size + 8) {
let chunk;
[chunk, offset] = parseChunk(buffer, offset);
chunks.push(chunk);
}
return chunks;
}
exports.parseWebP = parseWebP;
function getUint24le(view, offset = 0) {
return (view.getUint8(offset + 2) << 16) |
(view.getUint8(offset + 1) << 8) |
view.getUint8(offset);
}
function getUint24(view, offset) {
return (view.getUint8(offset ) << 16) |
(view.getUint8(offset + 1) << 8) |
view.getUint8(offset + 2);
}
/**
* @typedef Chunk
* @property {number} Size
* @property {ArrayBuffer} Payload
*/
/**
* Parse VP8 into keyframe and width/height.
* https://tools.ietf.org/html/rfc6386
* - section 19.1
* @param {Chunk} chunk
*/
function parseVP8(chunk) {
let view = new DataView(chunk.Payload);
let offset = 0;
// 3 byte frame tag
let tmp = getUint24le(view, offset);
offset += 3;
let key_frame = tmp & 0x1;
let version = (tmp >> 1) & 0x7;
let show_frame = (tmp >> 4) & 0x1;
let first_part_size = (tmp >> 5) & 0x7FFFF;
//assert(`VP8 chunk must be a key frame`, key_frame);
// 3 byte start code
let data_start = offset;
let start_code = getUint24(view, offset);
offset += 3;
assert(`start code ${start_code} must equal 0x9d012a`, start_code === 0x9d012a);
let horizontal_size_code = view.getUint16(offset, true);
offset += 2;
let width = horizontal_size_code & 0x3FFF;
let horizontal_scale = horizontal_size_code >> 14;
let vertical_size_code = view.getUint16(offset, true);
offset += 2;
let height = vertical_size_code & 0x3FFF;
let vertical_scale = vertical_size_code >> 14;
return {
width: width,
height: height,
data: chunk.Payload.slice(data_start)
};
}
/**
* @param {string} data_url
* @returns
*/
function getVP8FromDataUrl(data_url) {
// Skip up to ;
let encoded = data_url.slice(23);
// Decoded base64 data.
let buffer = Buffer.from(encoded, 'base64');
let chunks = parseWebP(buffer.buffer);
let vp8 = chunks.find((chunk) => chunk.FourCC === 'VP8 ');
assert('VP8 chunk must exist', vp8);
return parseVP8(vp8);
}
exports.getVP8FromDataUrl = getVP8FromDataUrl;
function Video(fps = null) {
this.frames = [];
this.fps = fps;
}
Video.prototype.add = function (frame, duration) {
if (typeof duration == 'undefined') {
if (!this.fps) {
throw new Error('Either duration must be provided with frame or fps provided on Video construction');
} else {
duration = 1000 / this.fps;
}
}
if (typeof frame != "string") {
throw "frame must be a a HTMLCanvasElement, a CanvasRenderingContext2D or a DataURI formatted string";
}
if (!(/^data:image\/webp;base64,/ig).test(frame)) {
throw "Input must be formatted properly as a base64 encoded DataURI of type image/webp";
}
this.frames.push({
image: frame,
duration: duration
});
};
Video.prototype.compile = function (outputAsArray) {
return new toWebM(this.frames.map(function (frame) {
let obj = getVP8FromDataUrl(frame.image);
obj.duration = frame.duration;
return obj;
}), outputAsArray)
};
/**
* Expose class-based compiler.
*/
exports.Video = Video;
/**
* @param images {Array.<string>} - base64-encoded webp images.
*/
exports.fromImageArray = function (images, fps, outputAsArray) {
return toWebM(images.map((image) => {
let obj = getVP8FromDataUrl(image);
obj.duration = 1000 / fps;
return obj;
}), outputAsArray);
};
exports.toWebM = toWebM;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment