Created
November 23, 2016 02:27
-
-
Save chrahunt/72fa2c6873c3ca6daaa766a9e3d81cc1 to your computer and use it in GitHub Desktop.
Attempted whammy rewrite. Unusable.
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
/** | |
* 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