diff options
Diffstat (limited to 'dom/media/test/eme.js')
-rw-r--r-- | dom/media/test/eme.js | 495 |
1 files changed, 495 insertions, 0 deletions
diff --git a/dom/media/test/eme.js b/dom/media/test/eme.js new file mode 100644 index 000000000..81f679762 --- /dev/null +++ b/dom/media/test/eme.js @@ -0,0 +1,495 @@ +const CLEARKEY_KEYSYSTEM = "org.w3.clearkey"; + +const gCencMediaKeySystemConfig = [{ + initDataTypes: ['cenc'], + videoCapabilities: [{ contentType: 'video/mp4' }], + audioCapabilities: [{ contentType: 'audio/mp4' }], +}]; + +function IsMacOSSnowLeopardOrEarlier() { + var re = /Mac OS X (\d+)\.(\d+)/; + var ver = navigator.userAgent.match(re); + if (!ver || ver.length != 3) { + return false; + } + var major = ver[1] | 0; + var minor = ver[2] | 0; + return major == 10 && minor <= 6; +} + +function bail(message) +{ + return function(err) { + if (err) { + message += "; " + String(err) + } + ok(false, message); + if (err) { + info(String(err)); + } + SimpleTest.finish(); + } +} + +function ArrayBufferToString(arr) +{ + var str = ''; + var view = new Uint8Array(arr); + for (var i = 0; i < view.length; i++) { + str += String.fromCharCode(view[i]); + } + return str; +} + +function StringToArrayBuffer(str) +{ + var arr = new ArrayBuffer(str.length); + var view = new Uint8Array(arr); + for (var i = 0; i < str.length; i++) { + view[i] = str.charCodeAt(i); + } + return arr; +} + +function StringToHex(str){ + var res = ""; + for (var i = 0; i < str.length; ++i) { + res += ("0" + str.charCodeAt(i).toString(16)).slice(-2); + } + return res; +} + +function Base64ToHex(str) +{ + var bin = window.atob(str.replace(/-/g, "+").replace(/_/g, "/")); + var res = ""; + for (var i = 0; i < bin.length; i++) { + res += ("0" + bin.charCodeAt(i).toString(16)).substr(-2); + } + return res; +} + +function HexToBase64(hex) +{ + var bin = ""; + for (var i = 0; i < hex.length; i += 2) { + bin += String.fromCharCode(parseInt(hex.substr(i, 2), 16)); + } + return window.btoa(bin).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function TimeRangesToString(trs) +{ + var l = trs.length; + if (l === 0) { return "-"; } + var s = ""; + var i = 0; + for (;;) { + s += trs.start(i) + "-" + trs.end(i); + if (++i === l) { return s; } + s += ","; + } +} + +function SourceBufferToString(sb) +{ + return ("SourceBuffer{" + + "AppendMode=" + (sb.AppendMode || "-") + + ", updating=" + (sb.updating ? "true" : "false") + + ", buffered=" + TimeRangesToString(sb.buffered) + + ", audioTracks=" + (sb.audioTracks ? sb.audioTracks.length : "-") + + ", videoTracks=" + (sb.videoTracks ? sb.videoTracks.length : "-") + + "}"); +} + +function SourceBufferListToString(sbl) +{ + return "SourceBufferList[" + sbl.map(SourceBufferToString).join(", ") + "]"; +} + +function UpdateSessionFunc(test, token, sessionType, resolve, reject) { + return function(ev) { + var msgStr = ArrayBufferToString(ev.message); + var msg = JSON.parse(msgStr); + + Log(token, "got message from CDM: " + msgStr); + is(msg.type, sessionType, TimeStamp(token) + " key session type should match"); + ok(msg.kids, TimeStamp(token) + " message event should contain key ID array"); + + var outKeys = []; + + for (var i = 0; i < msg.kids.length; i++) { + var id64 = msg.kids[i]; + var idHex = Base64ToHex(msg.kids[i]).toLowerCase(); + var key = test.keys[idHex]; + + if (key) { + Log(token, "found key " + key + " for key id " + idHex); + outKeys.push({ + "kty":"oct", + "kid":id64, + "k":HexToBase64(key) + }); + } else { + bail(token + " couldn't find key for key id " + idHex); + } + } + + var update = JSON.stringify({ + "keys" : outKeys, + "type" : msg.type + }); + Log(token, "sending update message to CDM: " + update); + + ev.target.update(StringToArrayBuffer(update)).then(function() { + Log(token, "MediaKeySession update ok!"); + resolve(ev.target); + }).catch(function(reason) { + bail(token + " MediaKeySession update failed")(reason); + reject(); + }); + } +} + +function MaybeCrossOriginURI(test, uri) +{ + if (test.crossOrigin) { + return "http://test2.mochi.test:8888/tests/dom/media/test/allowed.sjs?" + uri; + } else { + return uri; + } +} + +function AppendTrack(test, ms, track, token, loadParams) +{ + return new Promise(function(resolve, reject) { + var sb; + var curFragment = 0; + var resolved = false; + var fragments = track.fragments; + var fragmentFile; + + if (loadParams && loadParams.onlyLoadFirstFragments) { + fragments = fragments.slice(0, loadParams.onlyLoadFirstFragments); + } + + function addNextFragment() { + if (curFragment >= fragments.length) { + Log(token, track.name + ": end of track"); + resolve(); + resolved = true; + return; + } + + fragmentFile = MaybeCrossOriginURI(test, fragments[curFragment++]); + + var req = new XMLHttpRequest(); + req.open("GET", fragmentFile); + req.responseType = "arraybuffer"; + + req.addEventListener("load", function() { + Log(token, track.name + ": fetch of " + fragmentFile + " complete, appending"); + sb.appendBuffer(new Uint8Array(req.response)); + }); + + req.addEventListener("error", function(){info(token + " error fetching " + fragmentFile);}); + req.addEventListener("abort", function(){info(token + " aborted fetching " + fragmentFile);}); + + Log(token, track.name + ": addNextFragment() fetching next fragment " + fragmentFile); + req.send(null); + } + + Log(token, track.name + ": addSourceBuffer(" + track.type + ")"); + sb = ms.addSourceBuffer(track.type); + sb.addEventListener("updateend", function() { + if (ms.readyState == "ended") { + /* We can get another updateevent as a result of calling ms.endOfStream() if + the highest end time of our source buffers is different from that of the + media source duration. Due to bug 1065207 this can happen because of + inaccuracies in the frame duration calculations. Check if we are already + "ended" and ignore the update event */ + Log(token, track.name + ": updateend when readyState already 'ended'"); + if (!resolved) { + // Needed if decoder knows this was the last fragment and ended by itself. + Log(token, track.name + ": but promise not resolved yet -> end of track"); + resolve(); + resolved = true; + } + return; + } + Log(token, track.name + ": updateend for " + fragmentFile + ", " + SourceBufferToString(sb)); + addNextFragment(); + }); + + addNextFragment(); + }); +} + +//Returns a promise that is resolved when the media element is ready to have +//its play() function called; when it's loaded MSE fragments. +function LoadTest(test, elem, token, loadParams) +{ + if (!test.tracks) { + ok(false, token + " test does not have a tracks list"); + return Promise.reject(); + } + + var ms = new MediaSource(); + elem.src = URL.createObjectURL(ms); + + return new Promise(function (resolve, reject) { + var firstOpen = true; + ms.addEventListener("sourceopen", function () { + if (!firstOpen) { + Log(token, "sourceopen again?"); + return; + } + + firstOpen = false; + Log(token, "sourceopen"); + return Promise.all(test.tracks.map(function(track) { + return AppendTrack(test, ms, track, token, loadParams); + })).then(function() { + if (loadParams && loadParams.noEndOfStream) { + Log(token, "Tracks loaded"); + } else { + Log(token, "Tracks loaded, calling MediaSource.endOfStream()"); + ms.endOfStream(); + } + resolve(); + }).catch(function() { + Log(token, "error while loading tracks"); + }); + }) + }); +} + +// Same as LoadTest, but manage a token+"_load" start&finished. +// Also finish main token if loading fails. +function LoadTestWithManagedLoadToken(test, elem, manager, token, loadParams) +{ + manager.started(token + "_load"); + return LoadTest(test, elem, token, loadParams) + .catch(function (reason) { + ok(false, TimeStamp(token) + " - Error during load: " + reason); + manager.finished(token + "_load"); + manager.finished(token); + }) + .then(function () { + manager.finished(token + "_load"); + }); +} + +function SetupEME(test, token, params) +{ + var v = document.createElement("video"); + v.crossOrigin = test.crossOrigin || false; + v.sessions = []; + + v.closeSessions = function() { + return Promise.all(v.sessions.map(s => s.close().then(() => s.closed))).then( + () => { + v.setMediaKeys(null); + if (v.parentNode) { + v.parentNode.removeChild(v); + } + v.onerror = null; + v.src = null; + }); + }; + + // Log events dispatched to make debugging easier... + [ "canplay", "canplaythrough", "ended", "error", "loadeddata", + "loadedmetadata", "loadstart", "pause", "play", "playing", "progress", + "stalled", "suspend", "waiting", "waitingforkey", + ].forEach(function (e) { + v.addEventListener(e, function(event) { + Log(token, "" + e); + }, false); + }); + + // Finish the test when error is encountered. + v.onerror = bail(token + " got error event"); + + var onSetKeysFail = (params && params.onSetKeysFail) + ? params.onSetKeysFail + : bail(token + " Failed to set MediaKeys on <video> element"); + + // null: No session management in progress, just go ahead and update the session. + // [...]: Session management in progress, add {initDataType, initData} to + // this queue to get it processed when possible. + var initDataQueue = []; + function pushInitData(ev) + { + if (initDataQueue === null) { + initDataQueue = []; + } + initDataQueue.push(ev); + if (params && params.onInitDataQueued) { + params.onInitDataQueued(ev, ev.initDataType, StringToHex(ArrayBufferToString(ev.initData))); + } + } + + function processInitDataQueue() + { + if (initDataQueue === null) { return; } + // If we're processed all our init data null the queue to indicate encrypted event handled. + if (initDataQueue.length === 0) { + initDataQueue = null; + return; + } + var ev = initDataQueue.shift(); + + var sessionType = (params && params.sessionType) ? params.sessionType : "temporary"; + Log(token, "createSession(" + sessionType + ") for (" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ")"); + var session = v.mediaKeys.createSession(sessionType); + if (params && params.onsessioncreated) { + params.onsessioncreated(session); + } + v.sessions.push(session); + + return new Promise(function (resolve, reject) { + session.addEventListener("message", UpdateSessionFunc(test, token, sessionType, resolve, reject)); + Log(token, "session[" + session.sessionId + "].generateRequest(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ")"); + session.generateRequest(ev.initDataType, ev.initData).catch(function(reason) { + // Reject the promise if generateRequest() failed. Otherwise it will + // be resolve in UpdateSessionFunc(). + bail(token + ": session[" + session.sessionId + "].generateRequest(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ") failed")(reason); + reject(); + }); + }) + + .then(function(aSession) { + Log(token, "session[" + session.sessionId + "].generateRequest(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ") succeeded"); + if (params && params.onsessionupdated) { + params.onsessionupdated(aSession); + } + processInitDataQueue(); + }); + } + + function streamType(type) { + var x = test.tracks.find(o => o.name == type); + return x ? x.type : undefined; + } + + // If sessions are to be delayed we won't peform any processing until the + // callback the assigned here is called by the test. + if (params && params.delaySessions) { + params.ProcessSessions = processInitDataQueue; + } + + // Is this the first piece of init data we're processing? + var firstInitData = true; + v.addEventListener("encrypted", function(ev) { + if (firstInitData) { + Log(token, "got first encrypted(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + "), setup session"); + firstInitData = false; + pushInitData(ev); + + function chain(promise, onReject) { + return promise.then(function(value) { + return Promise.resolve(value); + }).catch(function(reason) { + onReject(reason); + return Promise.reject(); + }) + } + + var options = { initDataTypes: [ev.initDataType] }; + if (streamType("video")) { + options.videoCapabilities = [{contentType: streamType("video")}]; + } + if (streamType("audio")) { + options.audioCapabilities = [{contentType: streamType("audio")}]; + } + + var p = navigator.requestMediaKeySystemAccess(CLEARKEY_KEYSYSTEM, [options]); + var r = bail(token + " Failed to request key system access."); + chain(p, r) + .then(function(keySystemAccess) { + var p = keySystemAccess.createMediaKeys(); + var r = bail(token + " Failed to create MediaKeys object"); + return chain(p, r); + }) + + .then(function(mediaKeys) { + Log(token, "created MediaKeys object ok"); + mediaKeys.sessions = []; + var p = v.setMediaKeys(mediaKeys); + return chain(p, onSetKeysFail); + }) + + .then(function() { + Log(token, "set MediaKeys on <video> element ok"); + if (params && params.onMediaKeysSet) { + params.onMediaKeysSet(); + } + if (!(params && params.delaySessions)) { + processInitDataQueue(); + } + }) + } else { + if (params && params.delaySessions) { + Log(token, "got encrypted(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ") event, queue it in because we're delaying sessions"); + pushInitData(ev); + } else if (initDataQueue !== null) { + Log(token, "got encrypted(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ") event, queue it for later session update"); + pushInitData(ev); + } else { + Log(token, "got encrypted(" + ev.initDataType + ", " + StringToHex(ArrayBufferToString(ev.initData)) + ") event, update session now"); + pushInitData(ev); + processInitDataQueue(); + } + } + }); + return v; +} + +function SetupEMEPref(callback) { + var prefs = [ + [ "media.mediasource.enabled", true ], + [ "media.eme.apiVisible", true ], + [ "media.mediasource.webm.enabled", true ], + ]; + + if (SpecialPowers.Services.appinfo.name == "B2G" || + !manifestVideo().canPlayType("video/mp4")) { + // XXX remove once we have mp4 PlatformDecoderModules on all platforms. + prefs.push([ "media.use-blank-decoder", true ]); + } + + SpecialPowers.pushPrefEnv({ "set" : prefs }, callback); +} + +function fetchWithXHR(uri, onLoadFunction) { + var p = new Promise(function(resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open("GET", uri, true); + xhr.responseType = "arraybuffer"; + xhr.addEventListener("load", function () { + is(xhr.status, 200, "fetchWithXHR load uri='" + uri + "' status=" + xhr.status); + resolve(xhr.response); + }); + xhr.send(); + }); + + if (onLoadFunction) { + p.then(onLoadFunction); + } + + return p; +}; + +function once(target, name, cb) { + var p = new Promise(function(resolve, reject) { + target.addEventListener(name, function onceEvent(arg) { + target.removeEventListener(name, onceEvent); + resolve(arg); + }); + }); + if (cb) { + p.then(cb); + } + return p; +} |