diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index cc4653d..023e6f2 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -25,7 +25,7 @@ jobs: env: VITE_SORA_SIGNALING_URL: ${{ secrets.TEST_SIGNALING_URL }} VITE_SORA_CHANNEL_ID_PREFIX: ${{ secrets.TEST_CHANNEL_ID_PREFIX }} - VITE_ACCESS_TOKEN: ${{ secrets.TEST_SECRET_KEY }} + VITE_SECRET_KEY: ${{ secrets.TEST_SECRET_KEY }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -37,7 +37,7 @@ jobs: - run: pnpm exec playwright install ${{ matrix.browser }} --with-deps - run: pnpm exec playwright test --project=${{ matrix.browser }} env: - VITE_TEST_CHANNEL_ID_SUFFIX: _${{ matrix.node }} + VITE_SORA_CHANNEL_ID_SUFFIX: _${{ matrix.node }} # - uses: actions/upload-artifact@v4 # if: always() # with: diff --git a/check_stereo/main.ts b/check_stereo/main.ts index 880a8d7..78786f4 100644 --- a/check_stereo/main.ts +++ b/check_stereo/main.ts @@ -3,17 +3,31 @@ import Sora, { type SignalingNotifyMessage, type ConnectionPublisher, type ConnectionSubscriber, + type ConnectionOptions, } from "sora-js-sdk"; +import { generateJwt } from "../src/misc"; document.addEventListener("DOMContentLoaded", async () => { // 環境変数の読み込み const signalingUrl = import.meta.env.VITE_SORA_SIGNALING_URL; - - const uuid = crypto.randomUUID(); + const channelIdPrefix = import.meta.env.VITE_SORA_CHANNEL_ID_PREFIX; + const channelIdSuffix = import.meta.env.VITE_SORA_CHANNEL_ID_SUFFIX; + const secretKey = import.meta.env.VITE_SECRET_KEY; // Sora クライアントの初期化 - const sendonly = new SendonlyClient(signalingUrl, uuid); - const recvonly = new RecvonlyClient(signalingUrl, uuid); + const sendonly = new SendonlyClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, + ); + + const recvonly = new RecvonlyClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, + ); // デバイスリストの取得と設定 await updateDeviceLists(); @@ -76,8 +90,11 @@ async function updateDeviceLists() { class SendonlyClient { private debug = false; + private channelId: string; - private options: object = {}; + private options: ConnectionOptions; + + private secretKey: string; private sora: SoraConnection; private connection: ConnectionPublisher; @@ -87,23 +104,32 @@ class SendonlyClient { private channelCheckInterval: number | undefined; - constructor(signalingUrl: string, channelId: string) { - this.sora = Sora.connection(signalingUrl, this.debug); - - this.channelId = channelId; - - this.connection = this.sora.sendonly( - this.channelId, - undefined, - this.options, - ); + constructor( + signalingUrl: string, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, + options: ConnectionOptions = {}, + ) { + this.channelId = `${channelIdPrefix}:check_stereo:${channelIdSuffix}`; + this.secretKey = secretKey; + this.options = options; + this.sora = Sora.connection(signalingUrl, this.debug); + this.connection = this.sora.sendonly(this.channelId, null, this.options); this.connection.on("notify", this.onnotify.bind(this)); this.initializeCanvas(); } async connect(stream: MediaStream): Promise { + if (this.secretKey !== "") { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { + access_token: jwt, + }; + } + const audioTrack = stream.getAudioTracks()[0]; if (!audioTrack) { throw new Error("Audio track not found"); @@ -297,29 +323,36 @@ class SendonlyClient { class RecvonlyClient { private debug = false; + private channelId: string; private options: object = { video: false, audio: true, }; + private secretKey: string; + private sora: SoraConnection; private connection: ConnectionSubscriber; private canvas: HTMLCanvasElement | null = null; private canvasCtx: CanvasRenderingContext2D | null = null; - constructor(signalingUrl: string, channelId: string) { - this.channelId = channelId; + constructor( + signalingUrl: string, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, + ) { + this.channelId = `${channelIdPrefix}:check_stereo:${channelIdSuffix}`; + this.secretKey = secretKey; this.sora = Sora.connection(signalingUrl, this.debug); - this.connection = this.sora.recvonly( this.channelId, undefined, this.options, ); - this.connection.on("notify", this.onnotify.bind(this)); this.connection.on("track", this.ontrack.bind(this)); @@ -327,6 +360,9 @@ class RecvonlyClient { } async connect(): Promise { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { access_token: jwt }; + const forceStereoOutputElement = document.querySelector("#forceStereoOutput"); const forceStereoOutput = forceStereoOutputElement diff --git a/check_stereo_multi/main.ts b/check_stereo_multi/main.ts index 9c16056..adcddce 100644 --- a/check_stereo_multi/main.ts +++ b/check_stereo_multi/main.ts @@ -3,19 +3,42 @@ import Sora, { type SignalingNotifyMessage, type ConnectionPublisher, type ConnectionSubscriber, + type ConnectionOptions, } from "sora-js-sdk"; +import { generateJwt } from "../src/misc"; document.addEventListener("DOMContentLoaded", async () => { - // 環境変数の読み込み const signalingUrl = import.meta.env.VITE_SORA_SIGNALING_URL; - - const uuid = crypto.randomUUID(); + const channelIdPrefix = import.meta.env.VITE_SORA_CHANNEL_ID_PREFIX; + const channelIdSuffix = import.meta.env.VITE_SORA_CHANNEL_ID_SUFFIX; + const secretKey = import.meta.env.VITE_SECRET_KEY; // Sora クライアントの初期化 - const sendonly1 = new SendonlyClient(signalingUrl, uuid, 1); - const sendonly2 = new SendonlyClient(signalingUrl, uuid, 2); - - const recvonly = new RecvonlyClient(signalingUrl, uuid); + const sendonly1 = new SendonlyClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, + { + clientId: "1", + }, + ); + const sendonly2 = new SendonlyClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, + { + clientId: "2", + }, + ); + + const recvonly = new RecvonlyClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, + ); // デバイスリストの取得と設定 await updateDeviceLists(); @@ -104,8 +127,11 @@ async function updateDeviceLists() { class SendonlyClient { private debug = false; + private channelId: string; - private options: object = {}; + private options: ConnectionOptions; + + private secretKey: string; private sora: SoraConnection; private connection: ConnectionPublisher; @@ -115,31 +141,32 @@ class SendonlyClient { private channelCheckInterval: number | undefined; - private sendonlyClientId: number; - constructor( signalingUrl: string, - channelId: string, - sendonlyClientId: number, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, + options: ConnectionOptions = {}, ) { - this.sora = Sora.connection(signalingUrl, this.debug); - - this.channelId = channelId; - - this.sendonlyClientId = sendonlyClientId; - - this.connection = this.sora.sendonly( - this.channelId, - undefined, - this.options, - ); + this.channelId = `${channelIdPrefix}:check_stereo_multi:${channelIdSuffix}`; + this.secretKey = secretKey; + this.options = options; + this.sora = Sora.connection(signalingUrl, this.debug); + this.connection = this.sora.sendonly(this.channelId, null, this.options); this.connection.on("notify", this.onnotify.bind(this)); this.initializeCanvas(); } async connect(stream: MediaStream): Promise { + if (this.secretKey !== "") { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { + access_token: jwt, + }; + } + const audioTrack = stream.getAudioTracks()[0]; if (!audioTrack) { throw new Error("Audio track not found"); @@ -167,7 +194,7 @@ class SendonlyClient { private initializeCanvas() { this.canvas = document.querySelector( - `#sendonly-waveform-${this.sendonlyClientId}`, + `#sendonly-waveform-${this.options.clientId}`, ); if (this.canvas) { this.canvasCtx = this.canvas.getContext("2d"); @@ -211,7 +238,7 @@ class SendonlyClient { // differenceの値を表示する要素を追加 const differenceElement = document.querySelector( - `#sendonly-difference-value-${this.sendonlyClientId}`, + `#sendonly-difference-value-${this.options.clientId}`, ); if (differenceElement) { differenceElement.textContent = `Difference: ${difference.toFixed(6)}`; @@ -219,7 +246,7 @@ class SendonlyClient { // sendonly-stereo 要素に結果を反映 const sendonlyStereoElement = document.querySelector( - `#sendonly-stereo-${this.sendonlyClientId}`, + `#sendonly-stereo-${this.options.clientId}`, ); if (sendonlyStereoElement) { sendonlyStereoElement.textContent = result; @@ -310,7 +337,7 @@ class SendonlyClient { this.connection.connectionId === event.connection_id ) { const connectionIdElement = document.querySelector( - `#sendonly-connection-id-${this.sendonlyClientId}`, + `#sendonly-connection-id-${this.options.clientId}`, ); if (connectionIdElement) { connectionIdElement.textContent = event.connection_id; @@ -322,7 +349,7 @@ class SendonlyClient { this.channelCheckInterval = window.setInterval(async () => { const channels = await this.getChannels(); const channelElement = document.querySelector( - `#sendonly-channels-${this.sendonlyClientId}`, + `#sendonly-channels-${this.options.clientId}`, ); if (channelElement) { channelElement.textContent = @@ -337,10 +364,8 @@ class SendonlyClient { class RecvonlyClient { private debug = false; private channelId: string; - private options: object = { - video: false, - audio: true, - }; + private options: ConnectionOptions; + private secretKey: string; private sora: SoraConnection; private connection: ConnectionSubscriber; @@ -350,22 +375,31 @@ class RecvonlyClient { private canvases = new Map(); private canvasCtxs = new Map(); - constructor(signalingUrl: string, channelId: string) { - this.channelId = channelId; + constructor( + signalingUrl: string, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, + ) { + this.channelId = `${channelIdPrefix}:check_stereo_multi:${channelIdSuffix}`; + this.secretKey = secretKey; - this.sora = Sora.connection(signalingUrl, this.debug); + this.options = {}; + this.sora = Sora.connection(signalingUrl, this.debug); this.connection = this.sora.recvonly( this.channelId, undefined, this.options, ); - this.connection.on("notify", this.onnotify.bind(this)); this.connection.on("track", this.ontrack.bind(this)); } async connect(): Promise { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { access_token: jwt }; + const forceStereoOutputElement = document.querySelector("#forceStereoOutput"); const forceStereoOutput = forceStereoOutputElement diff --git a/messaging/main.ts b/messaging/main.ts index 58c6e8e..bc53dff 100644 --- a/messaging/main.ts +++ b/messaging/main.ts @@ -5,24 +5,22 @@ import Sora, { type DataChannelMessageEvent, type DataChannelEvent, } from "sora-js-sdk"; +import { generateJwt } from "../src/misc"; document.addEventListener("DOMContentLoaded", async () => { const signalingUrl = import.meta.env.VITE_SORA_SIGNALING_URL; - const channelId = import.meta.env.VITE_SORA_CHANNEL_ID || ""; - const accessToken = import.meta.env.VITE_ACCESS_TOKEN || ""; - - const soraJsSdkVersion = Sora.version(); - const soraJsSdkVersionElement = document.getElementById( - "sora-js-sdk-version", + const channelIdPrefix = import.meta.env.VITE_SORA_CHANNEL_ID_PREFIX || ""; + const channelIdSuffix = import.meta.env.VITE_SORA_CHANNEL_ID_SUFFIX || ""; + const secretKey = import.meta.env.VITE_SECRET_KEY || ""; + + const client = new SoraClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, ); - if (soraJsSdkVersionElement) { - soraJsSdkVersionElement.textContent = soraJsSdkVersion; - } - - let client: SoraClient; document.querySelector("#connect")?.addEventListener("click", async () => { - client = new SoraClient(signalingUrl, channelId, accessToken); const checkCompress = document.getElementById( "check-compress", ) as HTMLInputElement; @@ -81,15 +79,21 @@ class SoraClient { private debug = false; private channelId: string; - private metadata: { access_token: string }; private options: object; + private secretKey: string; + private sora: SoraConnection; private connection: ConnectionMessaging; - constructor(signalingUrl: string, channelId: string, accessToken: string) { - this.sora = Sora.connection(signalingUrl, this.debug); - this.channelId = channelId; - this.metadata = { access_token: accessToken }; + + constructor( + signalingUrl: string, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, + ) { + this.channelId = `${channelIdPrefix}:messaging:${channelIdSuffix}`; + this.secretKey = secretKey; this.options = { dataChannelSignaling: true, @@ -102,18 +106,21 @@ class SoraClient { ], }; - this.connection = this.sora.messaging( - this.channelId, - this.metadata, - this.options, - ); - + this.sora = Sora.connection(signalingUrl, this.debug); + this.connection = this.sora.messaging(this.channelId, null, this.options); this.connection.on("notify", this.onnotify.bind(this)); this.connection.on("datachannel", this.ondatachannel.bind(this)); this.connection.on("message", this.onmessage.bind(this)); } async connect(compress: boolean, header: boolean) { + if (this.secretKey !== "") { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { + access_token: jwt, + }; + } + // connect ボタンを無効にする const connectButton = document.querySelector("#connect"); if (connectButton) { diff --git a/package.json b/package.json index 8366ff9..6068256 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "vite", - "test": "pnpm build && playwright test --project=chromium", + "test": "playwright test --project=chromium", "lint": "biome lint .", "fmt": "biome format --write .", "check": "tsc --noEmit" @@ -36,4 +36,4 @@ "engines": { "node": ">=18" } -} +} \ No newline at end of file diff --git a/playwright.config.mjs b/playwright.config.mjs index cb3fe06..9bce263 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test"; // pnpm exec playwright test --ui export default defineConfig({ - testDir: "e2e-tests/tests", + testDir: "tests", // fullyParallel: true, reporter: "html", use: { diff --git a/recvonly/main.ts b/recvonly/main.ts index 72dd3d8..5a39bf1 100644 --- a/recvonly/main.ts +++ b/recvonly/main.ts @@ -3,15 +3,22 @@ import Sora, { type SignalingNotifyMessage, type ConnectionSubscriber, } from "sora-js-sdk"; +import { generateJwt } from "../src/misc"; document.addEventListener("DOMContentLoaded", () => { // 環境変数の読み込み const signalingUrl = import.meta.env.VITE_SORA_SIGNALING_URL; - const channelId = import.meta.env.VITE_SORA_CHANNEL_ID; - const accessToken = import.meta.env.VITE_ACCESS_TOKEN || ""; + const channelIdPrefix = import.meta.env.VITE_SORA_CHANNEL_ID_PREFIX; + const channelIdSuffix = import.meta.env.VITE_SORA_CHANNEL_ID_SUFFIX; + const secretKey = import.meta.env.VITE_SECRET_KEY; // Sora クライアントの初期化 - const client = new SoraClient(signalingUrl, channelId, accessToken); + const client = new SoraClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, + ); document.querySelector("#connect")?.addEventListener("click", async () => { await client.connect(); @@ -52,32 +59,40 @@ document.addEventListener("DOMContentLoaded", () => { class SoraClient { private debug = false; + private channelId: string; - private metadata: { access_token: string }; private options: object = {}; + private secretKey: string; + private sora: SoraConnection; private connection: ConnectionSubscriber; - constructor(signalingUrl: string, channelId: string, accessToken: string) { + constructor( + signalingUrl: string, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, + ) { this.sora = Sora.connection(signalingUrl, this.debug); - // channel_id の生成 - this.channelId = channelId; - // access_token を指定する metadata の生成 - this.metadata = { access_token: accessToken }; + this.channelId = `${channelIdPrefix}:recvonly:${channelIdSuffix}`; + this.secretKey = secretKey; - this.connection = this.sora.recvonly( - this.channelId, - this.metadata, - this.options, - ); + this.connection = this.sora.recvonly(this.channelId, null, this.options); this.connection.on("notify", this.onnotify.bind(this)); this.connection.on("track", this.ontrack.bind(this)); this.connection.on("removetrack", this.onremovetrack.bind(this)); } async connect(): Promise { + if (this.secretKey !== "") { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { + access_token: jwt, + }; + } + await this.connection.connect(); } @@ -113,7 +128,7 @@ class SoraClient { private ontrack(event: RTCTrackEvent) { // Sora の場合、event.streams には MediaStream が 1 つだけ含まれる const stream = event.streams[0]; - const remoteVideoId = `remotevideo-${stream.id}`; + const remoteVideoId = `remote-video-${stream.id}`; const remoteVideos = document.querySelector("#remote-videos"); if (remoteVideos && !remoteVideos.querySelector(`#${remoteVideoId}`)) { @@ -131,7 +146,7 @@ class SoraClient { private onremovetrack(event: MediaStreamTrackEvent) { // このトラックが属している MediaStream の id を取得する const stream = event.target as MediaStream; - const remoteVideo = document.querySelector(`#remotevideo-${stream.id}`); + const remoteVideo = document.querySelector(`#remote-video-${stream.id}`); if (remoteVideo) { document.querySelector("#remote-videos")?.removeChild(remoteVideo); } diff --git a/replace_track/index.html b/replace_track/index.html index eecdb37..15d4123 100644 --- a/replace_track/index.html +++ b/replace_track/index.html @@ -8,17 +8,14 @@

replaceTrack サンプル

-


@@ -34,7 +31,7 @@

- + \ No newline at end of file diff --git a/replace_track/main.mts b/replace_track/main.ts similarity index 84% rename from replace_track/main.mts rename to replace_track/main.ts index 8316350..2b77a31 100644 --- a/replace_track/main.mts +++ b/replace_track/main.ts @@ -5,6 +5,7 @@ import Sora, { type VideoCodecType, type ConnectionOptions, } from "sora-js-sdk"; +import { generateJwt } from "../src/misc"; const getVideoCodecType = (): VideoCodecType | undefined => { const videoCodecTypeElement = @@ -18,20 +19,22 @@ const getVideoCodecType = (): VideoCodecType | undefined => { document.addEventListener("DOMContentLoaded", async () => { const signalingUrl = import.meta.env.VITE_SORA_SIGNALING_URL; - const channelId = import.meta.env.VITE_SORA_CHANNEL_ID || ""; - const accessToken = import.meta.env.VITE_ACCESS_TOKEN || ""; + const channelIdPrefix = import.meta.env.VITE_SORA_CHANNEL_ID_PREFIX || ""; + const channelIdSuffix = import.meta.env.VITE_SORA_CHANNEL_ID_SUFFIX || ""; + const secretKey = import.meta.env.VITE_SECRET_KEY || ""; - let client: SoraClient; + const client = new SoraClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, + ); document.querySelector("#connect")?.addEventListener("click", async () => { const videoCodecType = getVideoCodecType(); - - client = new SoraClient( - signalingUrl, - channelId, - accessToken, - videoCodecType, - ); + if (videoCodecType !== undefined) { + client.setOptions({ videoCodecType: videoCodecType }); + } await client.connect(); }); @@ -84,9 +87,10 @@ class SoraClient { private debug = false; private channelId: string; - private metadata: { access_token: string }; private options: ConnectionOptions; + private secretKey: string; + private sora: SoraConnection; private connection: ConnectionPublisher; @@ -94,33 +98,36 @@ class SoraClient { constructor( signalingUrl: string, - channelId: string, - accessToken: string, - videoCodecType: VideoCodecType | undefined, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, + options: ConnectionOptions = {}, ) { - this.sora = Sora.connection(signalingUrl, this.debug); - this.channelId = channelId; - this.metadata = { access_token: accessToken }; - this.options = {}; - - if (videoCodecType !== undefined) { - this.options = { ...this.options, videoCodecType: videoCodecType }; - } + this.channelId = `${channelIdPrefix}:replace_track:${channelIdSuffix}`; + this.secretKey = secretKey; + this.options = options; this.stream = new MediaStream(); + this.sora = Sora.connection(signalingUrl, this.debug); this.connection = this.sora.sendrecv( this.channelId, - this.metadata, + undefined, this.options, ); - this.connection.on("notify", this.onnotify.bind(this)); this.connection.on("track", this.ontrack.bind(this)); this.connection.on("removetrack", this.onremovetrack.bind(this)); } async connect() { + if (this.secretKey !== "") { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { + access_token: jwt, + }; + } + await this.connection.connect(this.stream); const localVideo = document.querySelector("#local-video"); if (localVideo) { @@ -166,6 +173,10 @@ class SoraClient { return this.connection.pc.getStats(); } + setOptions(options: ConnectionOptions) { + this.connection.options = { ...this.connection.options, ...options }; + } + private onnotify(event: SignalingNotifyMessage): void { if ( event.event_type === "connection.created" && diff --git a/sendonly/main.ts b/sendonly/main.ts index e15a7f1..a8abd5b 100644 --- a/sendonly/main.ts +++ b/sendonly/main.ts @@ -3,14 +3,22 @@ import Sora, { type SignalingEvent, type ConnectionPublisher, type SoraConnection, + type ConnectionOptions, } from "sora-js-sdk"; +import { generateJwt } from "../src/misc"; document.addEventListener("DOMContentLoaded", async () => { const signalingUrl = import.meta.env.VITE_SORA_SIGNALING_URL; - const channelId = import.meta.env.VITE_SORA_CHANNEL_ID || ""; - const accessToken = import.meta.env.VITE_ACCESS_TOKEN || ""; + const channelIdPrefix = import.meta.env.VITE_SORA_CHANNEL_ID_PREFIX || ""; + const channelIdSuffix = import.meta.env.VITE_SORA_CHANNEL_ID_SUFFIX || ""; + const secretKey = import.meta.env.VITE_SECRET_KEY || ""; - const client = new SoraClient(signalingUrl, channelId, accessToken); + const client = new SoraClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, + ); document.querySelector("#connect")?.addEventListener("click", async () => { const stream = await navigator.mediaDevices.getUserMedia({ @@ -55,26 +63,28 @@ document.addEventListener("DOMContentLoaded", async () => { class SoraClient { private debug = false; + private channelId: string; - private metadata: { access_token: string }; - private options: object = {}; + private options: ConnectionOptions; + + private secretKey: string; private sora: SoraConnection; private connection: ConnectionPublisher; - constructor(signalingUrl: string, channelId: string, accessToken: string) { - this.sora = Sora.connection(signalingUrl, this.debug); - - // channel_id の生成 - this.channelId = channelId; - // access_token を指定する metadata の生成 - this.metadata = { access_token: accessToken }; + constructor( + signalingUrl: string, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, + options: ConnectionOptions = {}, + ) { + this.channelId = `${channelIdPrefix}:sendonly:${channelIdSuffix}`; + this.secretKey = secretKey; + this.options = options; - this.connection = this.sora.sendonly( - this.channelId, - this.metadata, - this.options, - ); + this.sora = Sora.connection(signalingUrl, this.debug); + this.connection = this.sora.sendonly(this.channelId, null, this.options); this.connection.on("notify", this.onNotify.bind(this)); // E2E テスト用のコード @@ -82,6 +92,13 @@ class SoraClient { } async connect(stream: MediaStream): Promise { + if (this.secretKey !== "") { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { + access_token: jwt, + }; + } + await this.connection.connect(stream); const videoElement = diff --git a/sendrecv/index.html b/sendrecv/index.html index 262e4a2..ce1deca 100644 --- a/sendrecv/index.html +++ b/sendrecv/index.html @@ -30,7 +30,7 @@

Sendrecv サンプル

- + \ No newline at end of file diff --git a/sendrecv/main.mts b/sendrecv/main.ts similarity index 83% rename from sendrecv/main.mts rename to sendrecv/main.ts index 4178e8d..b208cb8 100644 --- a/sendrecv/main.mts +++ b/sendrecv/main.ts @@ -5,6 +5,7 @@ import Sora, { type VideoCodecType, type ConnectionOptions, } from "sora-js-sdk"; +import { generateJwt } from "../src/misc"; const getVideoCodecType = (): VideoCodecType | undefined => { const videoCodecTypeElement = @@ -18,20 +19,22 @@ const getVideoCodecType = (): VideoCodecType | undefined => { document.addEventListener("DOMContentLoaded", async () => { const signalingUrl = import.meta.env.VITE_SORA_SIGNALING_URL; - const channelId = import.meta.env.VITE_SORA_CHANNEL_ID || ""; - const accessToken = import.meta.env.VITE_ACCESS_TOKEN || ""; + const channelIdPrefix = import.meta.env.VITE_SORA_CHANNEL_ID_PREFIX || ""; + const channelIdSuffix = import.meta.env.VITE_SORA_CHANNEL_ID_SUFFIX || ""; + const secretKey = import.meta.env.VITE_SECRET_KEY || ""; - let client: SoraClient; + const client = new SoraClient( + signalingUrl, + channelIdPrefix, + channelIdSuffix, + secretKey, + ); document.querySelector("#connect")?.addEventListener("click", async () => { const videoCodecType = getVideoCodecType(); - - client = new SoraClient( - signalingUrl, - channelId, - accessToken, - videoCodecType, - ); + if (videoCodecType !== undefined) { + client.setOptions({ videoCodecType: videoCodecType }); + } const stream = await navigator.mediaDevices.getUserMedia({ audio: true, @@ -76,41 +79,43 @@ class SoraClient { private debug = false; private channelId: string; - private metadata: { access_token: string }; private options: ConnectionOptions; + private secretKey: string; + private sora: SoraConnection; private connection: ConnectionPublisher; constructor( signalingUrl: string, - channelId: string, - accessToken: string, - videoCodecType: VideoCodecType | undefined, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, + options: ConnectionOptions = {}, ) { - this.sora = Sora.connection(signalingUrl, this.debug); - - this.channelId = channelId; - - this.metadata = { access_token: accessToken }; - this.options = {}; - - if (videoCodecType !== undefined) { - this.options = { ...this.options, videoCodecType: videoCodecType }; - } + this.channelId = `${channelIdPrefix}:sendrecv:${channelIdSuffix}`; + this.secretKey = secretKey; + this.options = options; + this.sora = Sora.connection(signalingUrl, this.debug); this.connection = this.sora.sendrecv( this.channelId, - this.metadata, + undefined, this.options, ); - this.connection.on("notify", this.onnotify.bind(this)); this.connection.on("track", this.ontrack.bind(this)); this.connection.on("removetrack", this.onremovetrack.bind(this)); } async connect(stream: MediaStream) { + if (this.secretKey !== "") { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { + access_token: jwt, + }; + } + await this.connection.connect(stream); const localVideo = document.querySelector("#local-video"); if (localVideo) { @@ -140,6 +145,10 @@ class SoraClient { return this.connection.pc.getStats(); } + setOptions(options: ConnectionOptions) { + this.options = { ...this.options, ...options }; + } + private onnotify(event: SignalingNotifyMessage): void { if ( event.event_type === "connection.created" && diff --git a/simulcast/index.html b/simulcast/index.html index ed9afcd..8e3b595 100644 --- a/simulcast/index.html +++ b/simulcast/index.html @@ -13,7 +13,7 @@

Simulcast サンプル


sendonly

-
+
@@ -37,7 +37,7 @@

recvonly r2

- + \ No newline at end of file diff --git a/simulcast/main.mts b/simulcast/main.ts similarity index 84% rename from simulcast/main.mts rename to simulcast/main.ts index 834ec62..1dc5e1f 100644 --- a/simulcast/main.mts +++ b/simulcast/main.ts @@ -1,12 +1,12 @@ -import jose from "jose"; - import Sora, { type SoraConnection, type ConnectionPublisher, type SignalingNotifyMessage, type ConnectionSubscriber, type SimulcastRid, + type ConnectionOptions, } from "sora-js-sdk"; +import { generateJwt } from "../src/misc"; document.addEventListener("DOMContentLoaded", () => { const signalingUrl = import.meta.env.VITE_SORA_SIGNALING_URL; @@ -19,27 +19,43 @@ document.addEventListener("DOMContentLoaded", () => { channelIdPrefix, channelIdSuffix, secretKey, + { + audio: false, + video: true, + videoCodecType: "VP8", + videoBitRate: 2500, + simulcast: true, + }, ); const recvonlyR0 = new SimulcastRecvonlySoraClient( signalingUrl, channelIdPrefix, channelIdSuffix, secretKey, - "r0", + { + simulcast: true, + simulcastRid: "r0", + }, ); const recvonlyR1 = new SimulcastRecvonlySoraClient( signalingUrl, channelIdPrefix, channelIdSuffix, secretKey, - "r1", + { + simulcast: true, + simulcastRid: "r1", + }, ); const recvonlyR2 = new SimulcastRecvonlySoraClient( signalingUrl, channelIdPrefix, channelIdSuffix, secretKey, - "r2", + { + simulcast: true, + simulcastRid: "r2", + }, ); document.querySelector("#connect")?.addEventListener("click", async () => { @@ -100,7 +116,9 @@ document.addEventListener("DOMContentLoaded", () => { class SimulcastSendonlySoraClient { private debug = false; + private channelId: string; + private options: ConnectionOptions; private secretKey: string; @@ -112,27 +130,28 @@ class SimulcastSendonlySoraClient { channelIdPrefix: string, channelIdSuffix: string, secretKey: string, + options: ConnectionOptions, ) { - this.channelId = `${channelIdPrefix}__simulcast__${channelIdSuffix}`; + this.channelId = `${channelIdPrefix}:simulcast:${channelIdSuffix}`; this.secretKey = secretKey; - this.sora = Sora.connection(signaling_url, this.debug); + this.options = options; - // metadata は接続時に設定する - this.connection = this.sora.sendonly(this.channelId, null, { - audio: false, - video: true, - videoCodecType: "VP8", - videoBitRate: 2500, - simulcast: true, - }); + this.sora = Sora.connection(signaling_url, this.debug); + this.connection = this.sora.sendonly( + this.channelId, + undefined, + this.options, + ); this.connection.on("notify", this.onnotify.bind(this)); } async connect(stream: MediaStream) { - const jwt = await generateJwt(this.channelId, this.secretKey); - this.connection.metadata = { - access_token: jwt, - }; + if (this.secretKey !== "") { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { + access_token: jwt, + }; + } await this.connection.connect(stream); @@ -176,7 +195,7 @@ class SimulcastRecvonlySoraClient { private debug = false; private channelId: string; - private rid: SimulcastRid; + private options: ConnectionOptions; private secretKey: string; @@ -188,17 +207,18 @@ class SimulcastRecvonlySoraClient { channelIdPrefix: string, channelIdSuffix: string, secretKey: string, - rid: SimulcastRid, + options: ConnectionOptions, ) { this.channelId = `${channelIdPrefix}__simulcast__${channelIdSuffix}`; this.secretKey = secretKey; - this.rid = rid; + this.options = options; this.sora = Sora.connection(signaling_url, this.debug); - this.connection = this.sora.recvonly(this.channelId, null, { - simulcastRid: this.rid, - simulcast: true, - }); + this.connection = this.sora.recvonly( + this.channelId, + undefined, + this.options, + ); this.connection.on("notify", this.onnotify.bind(this)); this.connection.on("track", this.ontrack.bind(this)); this.connection.on("removetrack", this.onremovetrack.bind(this)); @@ -214,12 +234,13 @@ class SimulcastRecvonlySoraClient { } async disconnect() { - if (this.connection === null) { + if (!this.connection) { return; } await this.connection.disconnect(); + const remoteVideo = document.querySelector( - `#remote-video-${this.rid}`, + `#remote-video-${this.options.simulcastRid}`, ); if (remoteVideo) { remoteVideo.srcObject = null; @@ -232,7 +253,7 @@ class SimulcastRecvonlySoraClient { event.connection_id === this.connection?.connectionId ) { const localVideoConnectionId = document.querySelector( - `#remote-video-connection-id-${this.rid}`, + `#remote-video-connection-id-${this.options.simulcastRid}`, ); if (localVideoConnectionId) { localVideoConnectionId.textContent = `${event.connection_id}`; @@ -242,7 +263,7 @@ class SimulcastRecvonlySoraClient { private ontrack(event: RTCTrackEvent) { const remoteVideo = document.querySelector( - `#remote-video-${this.rid}`, + `#remote-video-${this.options.simulcastRid}`, ); if (remoteVideo) { remoteVideo.srcObject = event.streams[0]; @@ -251,26 +272,10 @@ class SimulcastRecvonlySoraClient { private onremovetrack(event: MediaStreamTrackEvent) { const remoteVideo = document.querySelector( - `#remote-video-${this.rid}`, + `#remote-video-${this.options.simulcastRid}`, ); if (remoteVideo) { remoteVideo.srcObject = null; } } } - -const generateJwt = async ( - channelId: string, - secretKey: string, -): Promise => { - const header = { alg: "HS256", typ: "JWT" }; - const payload = { - // 30 秒後に有効期限切れ - exp: Math.floor(Date.now() / 1000) + 30, - channel_id: channelId, - }; - return await new jose.SignJWT(payload) - .setProtectedHeader(header) - .setIssuedAt() - .sign(new TextEncoder().encode(secretKey)); -}; diff --git a/spotlight_sendrecv/index.html b/spotlight_sendrecv/index.html index 201d891..542b34b 100644 --- a/spotlight_sendrecv/index.html +++ b/spotlight_sendrecv/index.html @@ -11,17 +11,17 @@

Spotlight Sendrecv サンプル

sendrecv

- -
-
- + \ No newline at end of file diff --git a/spotlight_sendrecv/main.mts b/spotlight_sendrecv/main.ts similarity index 61% rename from spotlight_sendrecv/main.mts rename to spotlight_sendrecv/main.ts index ec0c20d..b95e911 100644 --- a/spotlight_sendrecv/main.mts +++ b/spotlight_sendrecv/main.ts @@ -2,60 +2,56 @@ import Sora, { type SoraConnection, type ConnectionPublisher, type SignalingNotifyMessage, + type ConnectionOptions, } from "sora-js-sdk"; +import { generateJwt } from "../src/misc"; document.addEventListener("DOMContentLoaded", async () => { const signalingUrl = import.meta.env.VITE_SORA_SIGNALING_URL; - const channelId = import.meta.env.VITE_SORA_CHANNEL_ID || ""; - const accessToken = import.meta.env.VITE_ACCESS_TOKEN || ""; + const channelIdPrefix = import.meta.env.VITE_SORA_CHANNEL_ID_PREFIX || ""; + const channelIdSuffix = import.meta.env.VITE_SORA_CHANNEL_ID_SUFFIX || ""; + const secretKey = import.meta.env.VITE_SECRET_KEY || ""; const sendrecv = new SoraClient( - "sendrecv", signalingUrl, - channelId, - accessToken, + channelIdPrefix, + channelIdSuffix, + secretKey, ); - document - .querySelector("#sendrecv-connect") - ?.addEventListener("click", async () => { - const stream = await navigator.mediaDevices.getUserMedia({ - audio: true, - video: true, - }); - await sendrecv.connect(stream); - }); - document - .querySelector("#sendrecv-disconnect") - ?.addEventListener("click", async () => { - await sendrecv.disconnect(); + document.querySelector("#connect")?.addEventListener("click", async () => { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: true, }); + await sendrecv.connect(stream); + }); + document.querySelector("#disconnect")?.addEventListener("click", async () => { + await sendrecv.disconnect(); + }); }); class SoraClient { - // sendrecv1 or sendrecv2 - private label: string; - private debug = false; private channelId: string; - private metadata: { access_token: string }; - private options: object; + private options: ConnectionOptions; + + private secretKey: string; private sora: SoraConnection; private connection: ConnectionPublisher; constructor( - label: string, signalingUrl: string, - channelId: string, - accessToken: string, + channelIdPrefix: string, + channelIdSuffix: string, + secretKey: string, ) { - this.label = label; + this.secretKey = secretKey; this.sora = Sora.connection(signalingUrl, this.debug); - this.channelId = channelId; - this.metadata = { access_token: accessToken }; + this.channelId = `${channelIdPrefix}:spotlight_sendrecv:${channelIdSuffix}`; this.options = { audio: true, video: true, @@ -66,7 +62,7 @@ class SoraClient { this.connection = this.sora.sendrecv( this.channelId, - this.metadata, + undefined, this.options, ); @@ -76,10 +72,16 @@ class SoraClient { } async connect(stream: MediaStream) { + if (this.secretKey !== "") { + const jwt = await generateJwt(this.channelId, this.secretKey); + this.connection.metadata = { + access_token: jwt, + }; + } + await this.connection.connect(stream); - const localVideo = document.querySelector( - `#${this.label}-local-video`, - ); + + const localVideo = document.querySelector("#local-video"); if (localVideo) { localVideo.srcObject = stream; } @@ -89,14 +91,12 @@ class SoraClient { await this.connection.disconnect(); // お掃除 - const localVideo = document.querySelector( - `#${this.label}-local-video`, - ); + const localVideo = document.querySelector("#local-video"); if (localVideo) { localVideo.srcObject = null; } // お掃除 - const remoteVideos = document.querySelector(`#${this.label}-remote-videos`); + const remoteVideos = document.querySelector("#remote-videos"); if (remoteVideos) { remoteVideos.innerHTML = ""; } @@ -107,9 +107,8 @@ class SoraClient { event.event_type === "connection.created" && this.connection.connectionId === event.connection_id ) { - const connectionIdElement = document.querySelector( - `#${this.label}-connection-id`, - ); + const connectionIdElement = + document.querySelector("#connection-id"); if (connectionIdElement) { connectionIdElement.textContent = event.connection_id; } @@ -118,8 +117,9 @@ class SoraClient { private ontrack(event: RTCTrackEvent): void { const stream = event.streams[0]; - const remoteVideoId = `${this.label}-remote-video-${stream.id}`; - const remoteVideos = document.querySelector(`#${this.label}-remote-videos`); + const remoteVideoId = `remote-video-${stream.id}`; + const remoteVideos = + document.querySelector("#remote-videos"); if (remoteVideos && !remoteVideos.querySelector(`#${remoteVideoId}`)) { const remoteVideo = document.createElement("video"); remoteVideo.id = remoteVideoId; @@ -136,12 +136,12 @@ class SoraClient { private onremovetrack(event: MediaStreamTrackEvent): void { const target = event.target as MediaStream; - const remoteVideo = document.querySelector( - `#${this.label}-remote-video-${target.id}`, + const remoteVideo = document.querySelector( + `#remote-video-${target.id}`, ); if (remoteVideo) { document - .querySelector(`#${this.label}-remote-videos`) + .querySelector("#remote-videos") ?.removeChild(remoteVideo); } } diff --git a/src/misc.ts b/src/misc.ts new file mode 100644 index 0000000..07b9d49 --- /dev/null +++ b/src/misc.ts @@ -0,0 +1,17 @@ +import * as jose from "jose"; + +export const generateJwt = async ( + channelId: string, + secretKey: string, +): Promise => { + const header = { alg: "HS256", typ: "JWT" }; + return ( + new jose.SignJWT({ + channel_id: channelId, + }) + .setProtectedHeader(header) + // 30 秒後に有効期限切れ + .setExpirationTime("30s") + .sign(new TextEncoder().encode(secretKey)) + ); +}; diff --git a/tests/sendrecv.test.ts b/tests/sendrecv.test.ts new file mode 100644 index 0000000..334f594 --- /dev/null +++ b/tests/sendrecv.test.ts @@ -0,0 +1,152 @@ +import { expect, test } from "@playwright/test"; + +test("sendrecv x2", async ({ browser }) => { + const sendrecv1 = await browser.newPage(); + const sendrecv2 = await browser.newPage(); + + await sendrecv1.goto("http://localhost:9000/sendrecv/"); + await sendrecv2.goto("http://localhost:9000/sendrecv/"); + + // sendrecv1 のビデオコーデックをランダムに選択 + await sendrecv1.evaluate(() => { + const videoCodecTypes = ["VP8", "VP9", "AV1"]; + const randomIndex = Math.floor(Math.random() * videoCodecTypes.length); + const videoCodecTypeSelect = document.getElementById( + "video-codec-type", + ) as HTMLSelectElement; + videoCodecTypeSelect.value = videoCodecTypes[randomIndex]; + }); + + // sendrecv2 のビデオコーデックをランダムに選択 + await sendrecv2.evaluate(() => { + const videoCodecTypes = ["VP8", "VP9", "AV1"]; + const randomIndex = Math.floor(Math.random() * videoCodecTypes.length); + const videoCodecTypeSelect = document.getElementById( + "video-codec-type", + ) as HTMLSelectElement; + videoCodecTypeSelect.value = videoCodecTypes[randomIndex]; + }); + + // 選択されたコーデックをログに出力 + const sendrecv1VideoCodecType = await sendrecv1.$eval( + "#video-codec-type", + (el) => (el as HTMLSelectElement).value, + ); + const sendrecv2VideoCodecType = await sendrecv2.$eval( + "#video-codec-type", + (el) => (el as HTMLSelectElement).value, + ); + console.log(`sendrecv1 videoCodecType: ${sendrecv1VideoCodecType}`); + console.log(`sendrecv2 videoCodecType: ${sendrecv2VideoCodecType}`); + + await sendrecv1.click("#connect"); + await sendrecv2.click("#connect"); + + // #connection-id 要素が存在し、その内容が空でないことを確認するまで待つ + await sendrecv1.waitForSelector("#connection-id:not(:empty)"); + + // #connection-id 要素の内容を取得 + const sendrecv1ConnectionId = await sendrecv1.$eval( + "#connection-id", + (el) => el.textContent, + ); + console.log(`sendrecv1 connectionId=${sendrecv1ConnectionId}`); + + // #sendrecv1-connection-id 要素が存在し、その内容が空でないことを確認するまで待つ + await sendrecv2.waitForSelector("#connection-id:not(:empty)"); + + // #sendrecv1-connection-id 要素の内容を取得 + const sendrecv2ConnectionId = await sendrecv2.$eval( + "#connection-id", + (el) => el.textContent, + ); + console.log(`sendrecv2 connectionId=${sendrecv2ConnectionId}`); + + // レース対策 + await sendrecv1.waitForTimeout(3000); + await sendrecv2.waitForTimeout(3000); + + // page1 stats report + + // 'Get Stats' ボタンをクリックして統計情報を取得 + await sendrecv1.click("#get-stats"); + + // 統計情報が表示されるまで待機 + await sendrecv1.waitForSelector("#stats-report"); + // データセットから統計情報を取得 + const sendrecv1StatsReportJson: Record[] = + await sendrecv1.evaluate(() => { + const statsReportDiv = document.querySelector( + "#stats-report", + ) as HTMLDivElement; + return statsReportDiv + ? JSON.parse(statsReportDiv.dataset.statsReportJson || "[]") + : []; + }); + + const sendrecv1VideoCodecStats = sendrecv1StatsReportJson.find( + (stats) => + stats.type === "codec" && + stats.mimeType === `video/${sendrecv1VideoCodecType}`, + ); + expect(sendrecv1VideoCodecStats).toBeDefined(); + + const sendrecv1VideoOutboundRtpStats = sendrecv1StatsReportJson.find( + (stats) => stats.type === "outbound-rtp" && stats.kind === "video", + ); + expect(sendrecv1VideoOutboundRtpStats).toBeDefined(); + expect(sendrecv1VideoOutboundRtpStats?.bytesSent).toBeGreaterThan(0); + expect(sendrecv1VideoOutboundRtpStats?.packetsSent).toBeGreaterThan(0); + + const sendrecv1VideoInboundRtpStats = sendrecv1StatsReportJson.find( + (stats) => stats.type === "inbound-rtp" && stats.kind === "video", + ); + expect(sendrecv1VideoInboundRtpStats).toBeDefined(); + expect(sendrecv1VideoInboundRtpStats?.bytesReceived).toBeGreaterThan(0); + expect(sendrecv1VideoInboundRtpStats?.packetsReceived).toBeGreaterThan(0); + + // page2 stats report + + // 'Get Stats' ボタンをクリックして統計情報を取得 + await sendrecv2.click("#get-stats"); + + // 統計情報が表示されるまで待機 + await sendrecv2.waitForSelector("#stats-report"); + // データセットから統計情報を取得 + const sendrecv2StatsReportJson: Record[] = + await sendrecv2.evaluate(() => { + const statsReportDiv = document.querySelector( + "#stats-report", + ) as HTMLDivElement; + return statsReportDiv + ? JSON.parse(statsReportDiv.dataset.statsReportJson || "[]") + : []; + }); + + const sendrecv2VideoCodecStats = sendrecv2StatsReportJson.find( + (stats) => + stats.type === "codec" && + stats.mimeType === `video/${sendrecv2VideoCodecType}`, + ); + expect(sendrecv2VideoCodecStats).toBeDefined(); + + const sendrecv2VideoOutboundRtpStats = sendrecv2StatsReportJson.find( + (stats) => stats.type === "outbound-rtp" && stats.kind === "video", + ); + expect(sendrecv2VideoOutboundRtpStats).toBeDefined(); + expect(sendrecv2VideoOutboundRtpStats?.bytesSent).toBeGreaterThan(0); + expect(sendrecv2VideoOutboundRtpStats?.packetsSent).toBeGreaterThan(0); + + const sendrecv2VideoInboundRtpStats = sendrecv2StatsReportJson.find( + (stats) => stats.type === "inbound-rtp" && stats.kind === "video", + ); + expect(sendrecv2VideoInboundRtpStats).toBeDefined(); + expect(sendrecv2VideoInboundRtpStats?.bytesReceived).toBeGreaterThan(0); + expect(sendrecv2VideoInboundRtpStats?.packetsReceived).toBeGreaterThan(0); + + await sendrecv1.click("#disconnect"); + await sendrecv2.click("#disconnect"); + + await sendrecv1.close(); + await sendrecv2.close(); +});