Twitchエクステンションコードのリファレンス¶
サンプルの Twitchエクステンションでは、例を単純化するために JavaScript のみでクライアントコードを記述しています。このセクションでは、Twitchエクステンションのコードとそれに接続するために使用されるコードについて説明します。
In This Section
サーバーコード¶
パブリック URL を内部 Genvid サービスに接続するサーバーは、Twitch のドキュメントで Extension Backend Service (EBS) と呼ばれているものです。
チュートリアル用サンプル Web サイト のように、Node.js を使用してサーバーコードを記述しました。 このアプリケーションの主な違いは、cross-origin resource sharing (CORS) library を使用することです。
app.use(cors({ credentials: true, origin: true }));
Web サンプル同様、サーバーが 3 つのメソッドを転送します。
- /api/streams =>
GET /disco/stream/info
- このメソッドは、現在のストリームに関する情報 (ストリーム名、説明、チャンネルなど) を取得します。
- /api/channels/join =>
POST /disco/stream/join
- このメソッドは、Genvid EBS への接続に使用する URI とトークンを返します。
- /api/commands/game =>
POST /commands/game
この Web メソッドはゲームにコマンドを送信します。
このチュートリアルでは、Twitch ダッシュボードのライブ構成パネルからメソッドにアクセスできます。Genvid EBS は、送信されたユーザー/視聴者の JSON Web Token (JWT) を検証することにより、API 呼び出しを保護します。
JWT は、Twitchエクステンションの clientId を使用してデコードされます。検証が完了すると、視聴者/ユーザーは、エクステンションがインストールされたブロードキャスターになります。
utils/auth.ts
のファイルには、検証メソッドが含まれています。
/**
* Validate Twitch Authentication
* @param req
* @param res
* @param next
*/
export function twitchAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
let token: string = <string>req.headers['x-access-token'] || <string>req.headers['authorization'];
console.log('token', token);
if (token.startsWith('Bearer ')) {
// Remove Bearer from string
token = token.slice(7, token.length);
}
if (token) {
jwt.verify(token, Buffer.from(config.twitchExtConfig.secret, 'base64'), (err: any, decoded) => {
if (err) {
return res.status(401).send();
} else {
if (decoded.role === 'broadcaster') {
return next();
} else {
return res.status(401).send();
}
}
});
} else {
return res.status(401).send();
}
}
Twitchエクステンションのコード¶
Twitchエクステンションは、カラーキューブのライブビデオを表示するシンプルな Web アプリケーションです。マルチストリームとも対応しており、ピクチャ・イン・ピクチャ合成を試すこともできます。(詳細は Genvid Studio サンプル セクションを参照)。キューブの周りには輪を表示するビデオオーバーレイが存在します。輪の位置は、ビデオ内のキューブの位置と一致します。
エクステンションのコードも JavaScript のみで記述します。
ビデオオーバーレイ¶
ビデオオーバーレイ (video_overlay.html
) エクステンションは、透明なオーバーレイとしてビデオプレーヤーの上にレンダリングされます。チャンネルがライブのときのみ表示可能です。
オーバーレイには、ユーザーがストリームと対話できるようにするコントロールが含まれています。Tutorial Sample の場合、これらのコントロールには次のものが含まれます。
ツールバーボタン:
- Help = ヘルプメニューの表示、または非表示。
- Video info = ビデオ情報パネルの表示、または非表示。
- Cube name = キューブのコントロールパネルの表示、または非表示。
ビデオ:
- キューブをクリック = 輪を明るくする
キューブパネル:
- Cheer = プレイヤーの popularity を変更 (ハートアイコン)。
- Reset = キューブの位置をリセット。
- Color = キューブの色を変更。
Genvid オーバーレイは、Twitch プレイヤーとの直接の干渉を防ぎます。また、 Video Info ボタンをクリックしたときに、各キューブの横に、色付きの輪と名前、デバッグ情報を表示します。
- Local = Web ページを表示するマシンのローカル時間。
- Raw Video = ビデオプレイヤーの現在の内部時間。
- Est. Video = ビデオプレイヤーの予測時間。
- Last Compose = コンポジションサービスからレポートされた直前のデータの時間。
- Est. Compose = コンポジションサービスの予測時間。
- Stream = 一般化されたストリームの現在の時間。
- Latency = ゲームと視聴者間の予測遅延時間。
- DelayOffset = 同期の調整を行うための遅延オフセット調整値
viewer.js¶
viewer.js
のフロントエンドコードは、いくつかの必要な機能を実行します。
- 承認されたTwitchユーザー (
twitch.ext.onAuthorized
) であることを検証します。- Twitch Configuration Service から Genvid EBS の URL エンドポイントを取得します。
/api/public/channels/join
参加要求を行い、ストリーム情報と認証トークンを取得します。- ストリーム情報で GenvidClient をインスタンス化します。
/**
* 1- Validate that viewer is authorized to use a twitch extension
* 2- Retrieve the endpoint of the cluster from Twitch Configuration Service
* 3- Initiate Genvid Services...
*/
twitch.onAuthorized((auth) => {
// save our credentials
token = auth.token;
tuid = auth.userId;
// Retrieve genvid endpoint for stream info
let config = twitch.configuration.broadcaster;
twitch.rig.log('This is your configuration...', config);
let configuration = JSON.parse(config.content);
twitch.rig.log('let initiate the genvid services...');
if (configuration && configuration['web-endpoint']) {
const endpoint = configuration['web-endpoint'];
getStreamInfo(endpoint);
} else {
twitch.rig.log('no endpoint found');
}
});
IGenvidClient()
が Genvid サービスと通信を行い、コールバックを提供します。
onVideoPlayerReady()
は、ビデオの準備ができた時に呼び出されます。onAuthenticated()
は、認証成功を通知します。onStreamsReceived()
は、ストリームデータを受信したときにトリガされます。onDraw()
が定期的に呼び出され、GUI を更新します。
/**
* Callback called regurlary to draw the overlay,
* in sync with the video frame displayed.
* @param {*} frameSource
*/
const onDraw = frame => {
// update the overlays to adapt to the composition of the video stream:
updateOverlays(frame.compositionData);
// Parse the JSON from each elements
let gameDataFrame = frame.streams["GameData"];
let gameData = null;
if (gameDataFrame && gameDataFrame.user) {
gameData = gameDataFrame.user;
gameData.timeCode = frame.timeCode;
// Modify the popularity with the latest value
if (playerTableSetupCompletion === false) {
playerTableSetupCompletion = true;
initPlayerTable(gameData.cubes);
}
for (let cube of gameData.cubes) {
if (latestPopularity) {
let pCube = latestPopularity.find(c => c.name === cube.name);
if (pCube) {
cube.popularity = pCube.popularity;
}
}
}
// update video info component
updateInfo();
}
// Send the data to the overlay and controls
drawFrame(gameData);
/* if (onChannelDraw) {
onChannelDraw(gameData);
}*/
// Log the annotations
const colorChangedAnnotation = frame.annotations["ColorChanged"];
if (colorChangedAnnotation) {
for (let annotation of colorChangedAnnotation) {
let colorChanges = annotation.user;
for (let colorChange of colorChanges) {
console.info(`The cube ${colorChange.name} changed color.`);
}
}
}
if (isFullScreen !== checkFullScreen()) {
isFullScreen = checkFullScreen();
}
}
このサンプルでは、onDisconnect()
コールバック (IGenvidClient()
インターフェイスにより公開) を使って、クライアントのソケットが閉じるタイミングを通知します。
まずは、指定のコールバックをバインドします。
genvidClient.onDisconnect(() => {onDisconnectDetected();});
クライアントソケットの閉鎖通知が届いたら、スクリプトに再接続試行の指示を行います。
/**
* Method triggered when a disconnected socket occured We then initiate
* a client reconnect
*/
const onDisconnectDetected = () => {
twitch.rig.log('got disconnected, reconnect...');
const fetchOptions = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: "POST"
};
fetch(endpoint, fetchOptions)
.then(res => {
genvidClient.reconnect(res.data.info, res.data.uri, res.data.token);
resetFibNums();
})
.catch(error => __awaiter(this, void 0, void 0, function* () {
yield sleep(getNewSleepDuration());
onDisconnectDetected();
}));
}
次に、新しい leaf アドレス、新しいトークン、新しい streamInfo の情報リクエストを行います。
リクエストが成功した場合、reconnect()
を呼び出して、この情報を使用して新しい websocket 接続を確立します。
リクエストが失敗した場合 (利用可能な leaf がないなど)、しばらく待って再試行します。インクリメンタルなフィボナッチ数の計算結果を使用して、待機時間を決定します。
/**
* GENVID - Start sleep
* @param {*} ms
*/
const sleep = (ms) => {
return new Promise(resolve => setTimeout(resolve, ms));
}
フィボナッチ数列の結果に 10% のランダム要素を追加します。これにより、2 つのサービスが同時に接続を開始する状況を回避します。接続に成功したときにフィボナッチ数をリセットします。
オーバーレイメソッド¶
オーバーレイメソッド セクションで、ゲームデータを読み込みます。
/**
* GENVID - Start initOverlay
* Method that will initialize all HTML component
* for the overlay
*/
const initOverlay = () => {
twitch.rig.log("Init tutorial overlay...");
try {
genvidOverlay = document.querySelector("#genvid_overlay");
genvidOverlayButton = document.querySelector("#genvid_overlay_button");
videoOverlay = document.querySelector("#video_overlay");
canvas2d = document.querySelector("#canvas_overlay_2d");
// canvas3d = document.querySelector("#canvas_overlay_3d");
promptOverlay = document.querySelector("#prompt_overlay");
helpButton = document.querySelector("#help_button");
cubeName1Div = document.querySelector("#nameCube1");
cubeName2Div = document.querySelector("#nameCube2");
cubeName3Div = document.querySelector("#nameCube3");
videoPlayerTimeDiv = document.querySelector("#videoPlayerTime");
timeLocalDiv = document.querySelector("#time_local");
timeVideoDiv = document.querySelector("#time_video");
timeVideoRawDiv = document.querySelector("#time_video_raw");
timeComposeDiv = document.querySelector("#time_compose");
timeComposeLastDiv = document.querySelector("#time_compose_last");
timeStreamDiv = document.querySelector("#time_stream");
timeSessionDiv = document.querySelector("#time_session");
latencyDiv = document.querySelector("#latency");
delayOffsetDiv = document.querySelector("#delay_offset");
pipFrameDiv = document.querySelector("#pip_frame");
genvidWebGL = genvid.createWebGLContext(canvas3d); // Need to assign before any resize.
document.addEventListener("fullscreenchange", () => onResize());
document.addEventListener("webkitfullscreenchange", () => onResize());
document.addEventListener("mozfullscreenchange", () => onResize());
// playerElem.addEventListener("resize", () => onResize());
window.addEventListener("resize", () => onResize(), true);
window.addEventListener("orientationchange", () => onResize(), true);
window.addEventListener("sizemodechange", () => onResize(), true);
window.setInterval(() => onResize(), 1000); // Just a safety, in case something goes wrong.
onResize();
ctx = canvas2d.getContext("2d");
if (!ctx) {
console.log("No valid 2D context!");
}
mouseOverlay.addEventListener("mousemove", (event) => {
onMouseMouve(event);
}, true);
mouseOverlay.addEventListener("click", (event) => {
clickCube(event);
}, false);
/* genvidOverlayButton.addEventListener("click", (_event) => {
toggleGenvidOverlay();
}, false); */
// Add listeners to buttons
document.getElementById('toggle-help').addEventListener('click', (e) => {
onToggle(e);
}, true);
document.getElementById('toggle-video-info').addEventListener('click', (e) => {
onToggle(e);
}, true);
document.querySelector('.menu-controls').classList.remove('hidden');
// Initialize graphics stuff.
genvidWebGL.clear();
let gl = genvidWebGL.gl;
gl.disable(gl.DEPTH_TEST);
initShaders();
initRenderCommands();
}
catch (e) {
console.log("Exception during initialization:", e);
}
console.log("tutorial overlay initialized");
}
// GENVID - Stop initOverlay
/**
* GENVID - Start updateInfo
* Method that will display the video information
*/
const updateInfo = () => {
let w = 18; // Width of the content of every line (without label).
let localTime = new Date();
timeLocalDiv.textContent = `Local: ${msToDuration(Math.round(localTime.getTime()))}`;
let videoTimeRawMS = 0;
let videoPlayer = genvidClient.videoPlayer;
if (videoPlayer) {
videoTimeRawMS = videoPlayer.getCurrentTime() * 1000;
}
timeVideoRawDiv.textContent = `Raw Video: ${preN(msToDuration(Math.round(videoTimeRawMS)), w)}`;
timeVideoDiv.textContent = `Est. Video: ${preN(msToDuration(Math.round(genvidClient.videoTimeMS)), w)}`;
timeComposeLastDiv.textContent = `Last Compose: ${preN(msToDuration(Math.round(genvidClient.lastComposeTimeMS)), w)}`;
timeComposeDiv.textContent = `Est. Compose: ${preN(msToDuration(Math.round(genvidClient.composeTimeMS)), w)}`;
timeStreamDiv.textContent = `Stream: ${preN(msToDuration(Math.round(genvidClient.streamTimeMS)), w)}`;
if (game_data) {
timeSessionDiv.textContent = `Session: ${preN(msToDuration(Math.round(game_data.timeCode)), w)}`;
} else {
timeSessionDiv.textContent = `Session: <none>`;
}
latencyDiv.textContent = `Latency: ${preN(genvidClient.streamLatencyMS.toFixed(0), w - 3)} ms`;
delayOffsetDiv.textContent = `DelayOffset: ${preN(genvidClient.delayOffset.toFixed(0), w - 3)} ms`;
}
// GENVID - Stop updateInfo
/**
* GENVID - Start initPlayerTable
* Init all control panels for cubes
* @param {*} cubes
*/
const initPlayerTable = (cubes) => {
let gameControlDiv = document.getElementById("game-controls-div");
cubes.forEach(cube => {
let cubeAdd = `
<div id='cube_`+ cube.name + `' class='cube clickable cube` + cube.name + ` overlay-panel cube-control-container hidden'>
<div>
<span class='cube_name clickable'>` + cube.name + `</span>
<button class='cheer' id='` + cube.name + `_cheerbutton'>Cheer</button>
<span class='cheer_value cube` + cube.name + `_cheer'></span>
</div>
<div>
<span class='label clickable cube` + cube.name + `_reset'>Reset</span>
<span id='cube` + cube.name + `_position_x' class='cube_position'></span>
<span id='cube` + cube.name + `_position_y' class='cube_position'></span>
<span id='cube` + cube.name + `_position_z' class='cube_position'></span>
</div>
<table class='cube_color text-center'>
<tr>
<td class='clickable color` + cube.name + `_green'>Green</td>
<td class='clickable color` + cube.name + `_white'>White</td>
<td class='clickable color` + cube.name + `_yellow'>Yellow</td>
</tr>
<tr>
<td class='clickable color` + cube.name + `_darkblue'>Dark blue</td>
<td class='clickable color` + cube.name + `_grey'>Grey</td>
<td class='clickable color` + cube.name + `_lightblue''>Light Blue</td>
</tr>
<tr>
<td class='clickable color` + cube.name + `_orange'>Orange</td>
<td class='clickable color` + cube.name + `_blue'>Blue</td>
<td class='clickable color` + cube.name + `_purple'>Purple</td>
</tr>
</table>
</div>
`;
gameControlDiv.innerHTML += cubeAdd;
});
cubes.forEach(cube => {
let cheerButton = document.querySelector("#" + cube.name + "_cheerbutton");
cheerButton.addEventListener("click", (_event) => { _event.stopPropagation(); onCheer(cube.name); }, false);
cheerButton.addEventListener("dblclick", (e) => { e.stopPropagation(); }, false);
let cubeDiv = document.querySelector(".cube" + cube.name);
cubeDiv.addEventListener("click", (_event) => { onSelect(cube.name, true); }, false);
cubeDivMain.push(cubeDiv);
let resetButton = document.querySelector(".cube" + cube.name + "_reset");
resetButton.addEventListener("click", (_event) => { onReset(cube.name); }, false);
for (let colorSelect of tableColor) {
let colorButton = document.querySelector(".color" + cube.name + "_" + colorSelect[0]);
colorButton.addEventListener("click", (_event) => { onColorChange(cube.name, colorSelect[1]); }, false);
}
let toggleBtn = document.createElement('BUTTON');
toggleBtn.setAttribute('type', 'button');
toggleBtn.setAttribute('data-component', `cube_${cube.name}`);
toggleBtn.classList.add('btn-default', 'clickable');
toggleBtn.innerHTML = cube.name;
toggleBtn.addEventListener('click', (e) => { onToggle(e); }, false);
let menuItem = document.createElement('li');
menuItem.appendChild(toggleBtn);
document.querySelector('#menu-control-items').appendChild(menuItem);
});
}
// GENVID - Stop initPlayerTable
/**
* GENVID - Start updateUverlays
* @param {*} compositionData
*/
const updateOverlays = (compositionData) => {
// when a picture in picture type composition is sent, the first element of
// the composition data is the background frame and the second one is the picture in picture frame
if (compositionData && compositionData.length > 1) {
// composition picture in picture only: update the clipping on the 3d viewport
// to prevent the 3d overlay to overlay the secondary screen
pipFrameDiv.style.display = "block";
// set up the main canvas to pip affine transform matrix
const pipMat = genvidMath.mat3FromArray(compositionData[1].affineMatrix);
updateDomRect(pipFrameDiv, videoOverlay, pipMat);
updateDomClipping(overlay.canvas3d, pipMat);
}
else {
pipFrameDiv.style.display = "hide";
if (canvas3d)
canvas3d.style.removeProperty("clip-path");
}
}
// GENVID - Stop updateOverlays
/**
* GENVID - Start updateDomRect
* @param {*} targetDom
* @param {*} referenceDom
* @param {*} mat
*/
const updateDomRect = (targetDom, referenceDom, mat) => {
// get the canvas rect
const domQuad = genvidMath.Path2.makeQuad(0, 0, 1, 1);
const pipQuad = new genvidMath.Path2(domQuad.getPoints());
pipQuad.transform(mat);
pipQuad.scale($(referenceDom).width(), $(referenceDom).height());
const bbox = pipQuad.getBoundingBox();
targetDom.style.left = `${bbox.x}px`;
targetDom.style.top = `${bbox.y}px`;
targetDom.style.width = `${bbox.width}px`;
targetDom.style.height = `${bbox.height}px`;
}
// GENVID - Stop updateDomRect
/**
* GENVID - Start updateDomClipping
* @param {*} dom
* @param {*} mat
*/
const updateDomClipping = (dom, mat) => {
// get the canvas rect
const domQuad = genvidMath.Path2.makeQuad(0, 0, 1, 1);
const pipQuad = new genvidMath.Path2(domQuad.getPoints());
pipQuad.transform(mat);
pipQuad.reverse();
domQuad.append(pipQuad);
domQuad.scale($(dom).width(), $(dom).height());
dom.style.clipPath = domQuad.toCssPath();
}
// GENVID - Stop updateDomClipping
/**
* GENVID - Start drawFrame
*
* @param {*} gameData
*/
const drawFrame = (gameData) => {
if (!ctx) {
return;
}
if (gameData) {
game_data = gameData;
}
if (promptOverlay.style.visibility === "visible" && timeVisiblePrompt < timeVisibleMax) {
timeVisiblePrompt++;
if (volumeChange === 2) {
volumeChange = 0;
promptOverlay.textContent = "Volume: " + genvidClient.videoPlayer.getVolume().toString() + " %";
}
else if (volumeChange === 1) {
volumeChange = 0;
promptOverlay.textContent = "Volume: " + genvidClient.videoPlayer.getVolume().toString() + " %";
}
}
else if (promptOverlay.style.visibility === "visible" && timeVisiblePrompt >= timeVisibleMax) {
promptOverlay.style.visibility = "hidden";
}
update3D();
ctx.clearRect(0, 0, canvas2d.width, canvas2d.height);
draw3D();
updateInfo();
}
// GENVID - drawFrame stop
WebGL メソッド¶
WebGL メソッド セクションでは、ゲームデータを使用して WebGL キャンバスで 3D の円をレンダリングします。
/**
* GENVID - Start draw3D
* Function used to draw the WebGL 3d
*/
const draw3D = () => {
// let genvidWebGL = this.genvidWebGL;
let gl = genvidWebGL.gl;
genvidWebGL.clear();
let prog = gfx_prog;
gl.useProgram(prog);
// We prepare the program only once (good thing we have a single material).
gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(1);
gl.enableVertexAttribArray(2);
gl.activeTexture(gl.TEXTURE0);
gl.uniform1i(gl.getUniformLocation(prog, "tex"), 0);
if (gfx_prog_data_viewproj) {
gl.uniformMatrix4fv(gfx_prog_loc_viewproj, false, gfx_prog_data_viewproj);
}
// Draw commands.
let cmds = [gfx_cmd_test, gfx_cmd_cubes];
for (let cmd of cmds) {
if (cmd.visible && cmd.vtx) {
gl.bindBuffer(gl.ARRAY_BUFFER, cmd.vtx[0]);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 9 * 4, 0 * 4); // Position.
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 9 * 4, 3 * 4); // TexCoord.
gl.vertexAttribPointer(2, 4, gl.FLOAT, false, 9 * 4, 5 * 4); // Color.
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, cmd.idx[0]);
gl.bindTexture(gl.TEXTURE_2D, cmd.tex);
genvidWebGL.checkGLError();
gl.drawElements(gl.TRIANGLES, cmd.idx[1], gl.UNSIGNED_SHORT, 0);
genvidWebGL.checkGLError();
}
}
}
// GENVID - Stop draw3D
/**
* GENVID - Start initShaders
* Web GL initalization of shaders
*/
const initShaders = () => {
// let genvidWebGL = this.genvidWebGL;
let vShaderStr = [
"uniform mat4 g_ViewProjMat;",
"attribute vec3 g_Position;",
"attribute vec2 g_TexCoord0;",
"attribute vec4 g_Color0;",
"varying vec2 texCoord;",
"varying vec4 color;",
"void main()",
"{",
" gl_Position = g_ViewProjMat * vec4(g_Position, 1.0);",
" texCoord = g_TexCoord0;",
" color = g_Color0;",
"}"
].join("\n");
let fShaderStr = [
"precision mediump float;",
"uniform sampler2D tex;",
"varying vec2 texCoord;",
"varying vec4 color;",
"void main()",
"{",
" vec4 texColor = texture2D(tex, texCoord);",
" gl_FragColor = texColor * color;",
// " gl_FragColor = color;", // Just the color.
// " gl_FragColor = texColor;", // Just the texture.
// " gl_FragColor = vec4(texCoord, 0.0, 1.0);", // The texture coordinate.
// " gl_FragColor = vec4(fract(texCoord), 0.0, 1.0);", // Repeating texture coordinate.
"}"
].join("\n");
let vsh = genvidWebGL.loadVertexShader(vShaderStr);
let fsh = genvidWebGL.loadFragmentShader(fShaderStr);
gfx_prog = genvidWebGL.loadProgram(vsh, fsh, ["g_Position", "g_TexCoord0"]);
gfx_prog_loc_viewproj = genvidWebGL.gl.getUniformLocation(gfx_prog, "g_ViewProjMat");
}
// GENVID - Stop initShaders
/**
* GENVID - Start initRenderCommands
* Web gl initialization of render command
*/
const initRenderCommands = () => {
// let genvidWebGL = this.genvidWebGL;
let gl = genvidWebGL.gl;
// Utility function.
const handleTextureLoaded = (image, texture, options) => {
options = options || {};
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
if (options.wrap) {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, options.wrap);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, options.wrap);
}
if (options.aniso) {
let ext = (gl.getExtension("EXT_texture_filter_anisotropic") ||
gl.getExtension("MOZ_EXT_texture_filter_anisotropic") ||
gl.getExtension("WEBKIT_EXT_texture_filter_anisotropic"));
if (ext) {
let max = gl.getParameter(ext.MAX_TEXTURE_MAX_ANISOTROPY_EXT);
// console.log("Enabling aniso filtering", max);
gl.texParameterf(gl.TEXTURE_2D, ext.TEXTURE_MAX_ANISOTROPY_EXT, max);
}
}
gl.generateMipmap(gl.TEXTURE_2D);
gl.bindTexture(gl.TEXTURE_2D, null);
}
;
let onload = draw3D.bind(this);
// Test (TODO: remove).
{
let vertices = [];
makeCircleZ(vertices, 0, 0, 0, 0.5, genvidMath.vec4(1, 0, 0, 1));
let num_quads = vertices.length / (4 * 9);
let cmd = new RenderCommand();
let options = {
"wrap": gl.CLAMP_TO_EDGE,
"aniso": true,
};
cmd.vtx = genvidWebGL.createBuffer(new Float32Array(vertices));
cmd.idx = genvidWebGL.createIndexBufferForQuads(num_quads);
cmd.tex = gl.createTexture();
cmd.img = new Image();
cmd.img.onload = () => {
handleTextureLoaded(cmd.img, cmd.tex, options); if (onload)
onload();
};
cmd.img.src = "img/highlight_full_pa.png";
cmd.visible = false;
gfx_cmd_test = cmd;
}
// Cubes.
{
// Only prepare textures.
let cmd = new RenderCommand();
let options = {
"wrap": gl.CLAMP_TO_EDGE,
"aniso": true,
};
cmd.tex = gl.createTexture();
cmd.img = new Image();
cmd.img.onload = () => {
handleTextureLoaded(cmd.img, cmd.tex, options); if (onload)
onload();
};
cmd.img.src = "img/highlight_full_pa.png";
cmd.visible = true;
gfx_cmd_cubes = cmd;
}
draw3D();
}
// GENVID - Stop initRenderCommands
/**
* GENVID - Start update3D
* Updates 3D canvas
*/
const update3D = () => {
if (!game_data) {
return;
}
let cubes = game_data.cubes;
if (cubes) {
let vertices = [];
cubes.forEach(cube => {
let m = cube.mat;
let p = genvidMath.vec3(m[12], m[13], m[14]);
let r = circleRadius;
let c = genvidMath.vec4(1, 0, 0, 1);
if (cube.color) {
c = genvidMath.vec4(cube.color[0], cube.color[1], cube.color[2], cube.color[3]);
}
if (cube.selected && selection.length === 0) {
// To gather initial state.
setSelection(cube.name);
}
if (isSelected(cube.name)) {
c = genvidMath.muls4D(c, 0.5);
}
makeCircleZ(vertices, p.x, p.y, p.z, r, c);
let tag = findOrCreateTagDiv(cube);
let mat = convertMatrix(game_data.MatProjView);
let pos_2d = genvidMath.projectPosition(mat, p);
center_at(tag, pos_2d, genvidMath.vec2(0, -75));
cube.popText = popularityToText(cube.popularity);
document.querySelector(".cube" + cube.name + "_cheer").textContent = cube.popText;
document.querySelector("#cube" + cube.name + "_position_x").textContent = cube.mat[12].toFixed(2);
document.querySelector("#cube" + cube.name + "_position_y").textContent = cube.mat[13].toFixed(2);
document.querySelector("#cube" + cube.name + "_position_z").textContent = cube.mat[14].toFixed(2);
})
let num_quads = vertices.length / (4 * 9);
// let genvidWebGL = genvidWebGL;
let cmd = gfx_cmd_cubes;
cmd.vtx = genvidWebGL.createBuffer(new Float32Array(vertices));
cmd.idx = genvidWebGL.createIndexBufferForQuads(num_quads);
}
let mat = game_data.MatProjView;
gfx_prog_data_viewproj = mat;
}
// GENVID - Stop update3D
ユーザーインタラクション¶
ユーザーインタラクション セクションでは、ユーザーがオーバーレイを操作する方法を定義し、キューブの色の変更、キューブの応援、位置のリセットなどのイベントを生成します。
例として次のコードは、色を変更するイベントを示します。
/**
* Method used when clicking on a color to change the color of a cube
* @param {*} cube
* @param {*} color
*/
const onColorChange = (cube, color) => {
let evt = {
key: ["changeColor", cube],
value: color,
};
genvidClient.sendEvent([evt]);
}
ダッシュボード (Live Config) パネル¶
Live Configuration Panel (live_config.html
) は Command Channel へのアクセス方法です。インストールされたエクステンションのダッシュボードページのパネル領域に表示されます。
Twitch アカウントから:
Creator Dashboard を選択します。
Extensions セクションでパネルをアクティブにします。
パネルには、各キューブのセクションが表示されており、以下の機能が含まれています。
- Direction = 矢印をクリックしてキューブの向きを変更する。
- Reset = キューブの位置をリセットする。
- Slower = キューブのスピードを落とす。
- Faster = キューブのスピードを上げる。
live_config.js¶
live_config.js
ファイルは Command Channel を使ってコマンドをゲームに送信します。これは、配信者がキューブの速度と方向を変更する方法です。
コマンドはゲームを 直接 制御するため、配信者だけがコマンド API にアクセスできます。すべてのリクエストで、ユーザーの Bearer Token (JWT) を EBS に送信して、ユーザーが有効な Twitch ユーザーであり、配信者であることを検証します。
例として次のコードは、キューブの向きを変更するイベントを示します。
/**
* Change the direction of the cube
* @param {*} cubeName
* @param {*} x
* @param {*} z
*/
const setDirection = (cubeName, x, z) => {
let command = {
id: "direction",
value: `${cubeName}:${x}:0:${z}`
};
twitch.rig.log("command", command);
const fetchOptions = {
method: "POST",
headers: headers, // Contains the Bearer token
body: JSON.stringify(command)
};
fetch(commandApiEndpoint, fetchOptions)
.then(data => {
console.log('data', data);
message = `Set direction to ${cubeName}:${x}:${z}`;
displayMessage();
})
.catch(err => {
message = `Failed with error ${err} to do Set direction to ${cubeName}:${x}:${z}`;
console.log('err', err);
displayErrorMessage();
});
}
utils/auth.ts
ファイルは、 Node.js サーバー (EBS) の検証を行います。
/**
* Validate Twitch Authentication
* @param req
* @param res
* @param next
*/
export function twitchAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
let token: string = <string>req.headers['x-access-token'] || <string>req.headers['authorization'];
console.log('token', token);
if (token.startsWith('Bearer ')) {
// Remove Bearer from string
token = token.slice(7, token.length);
}
if (token) {
jwt.verify(token, Buffer.from(config.twitchExtConfig.secret, 'base64'), (err: any, decoded) => {
if (err) {
return res.status(401).send();
} else {
if (decoded.role === 'broadcaster') {
return next();
} else {
return res.status(401).send();
}
}
});
} else {
return res.status(401).send();
}
}
設定ページ¶
設定ページ では、チャネルごとおよびエクステンションごとの永続データを格納できます。 エクステンションの起動時に、そのデータをフロントエンドに提供します。
今回の例では、 Node.js サーバー (EBS) の API エンドポイントの格納に使用します。
config.js¶
config.js
ファイルは、フォーム情報を取得して Twitch 構成サービスに保存する場所です。
/* Quick and dirty binding form to twitch extension configuration */
const GvBindTwitchConfig = (form, twitchExtConfiguration) => {
// On Submit, serialize form values into Twitch
form.addEventListener("submit", function (e) {
var configuration = GvSerializeForm(form);
twitchExtConfiguration.set('broadcaster', '1.0', configuration);
const msg = document.getElementById("message");
if (msg.classList.contains('gv-displaynone')) {
msg.classList.remove('gv-displaynone');
}
e.preventDefault(); //stop form from submitting
});
// On Load, deserialize Twitch configuration into form values
var content = twitchExtConfiguration.broadcaster ? twitchExtConfiguration.broadcaster.content : {};
GvDeserializeForm(form, content);
}