Twitch 拡張機能コード参照

サンプルの Twitch 拡張機能では、例を単純化するために JavaScript のみでクライアントコードを記述しています。このセクションでは、Twitch 拡張機能のコードとそれに接続するために使用されるコードについて説明します。

サーバーコード

パブリック 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 sample セクションを参照)。キューブの周りには輪を表示するビデオオーバーレイが存在します。輪の位置は、ビデオ内のキューブの位置と一致します。

拡張機能のコードも JavaScript のみで記述します。

ビデオオーバーレイ

ビデオオーバーレイ (video_overlay.html) 拡張機能は、透明なオーバーレイとしてビデオプレーヤーの上にレンダリングされます。チャンネルがライブのときのみ表示可能です。

Twitch Extension Overlay

オーバーレイには、ユーザーがストリームと対話できるようにするコントロールが含まれています。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 サービスと通信を行い、コールバックを提供します。

/**
 * 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 アカウントから:

  1. Creator Dashboard を選択します。

  2. Extensions セクションでパネルをアクティブにします。

Twitch Extension Live Config Panel

パネルには、各キューブのセクションが表示されており、以下の機能が含まれています。

  • 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);
}