Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

create VideoPlayerStatsForNerds.vue #1403

Merged
merged 1 commit into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions src/components/VideoPlayerStatsForNerds.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
<template>
<div class="canvas-container">
<canvas ref="canvasRef" :width="width" :height="height"></canvas>
</div>
</template>

<script lang="ts" setup>
import { WebRTCStats } from '@peermetrics/webrtc-stats'
import { onMounted, onUnmounted, ref, watch } from 'vue'

import { useVideoStore } from '@/stores/video'
import { WebRTCStatsEvent } from '@/types/video'
const videoStore = useVideoStore()

const props = defineProps({
width: {
type: Number,
default: 130,
},
height: {
type: Number,
default: 200,
},
updateInterval: {
type: Number,
default: 20,
},
streamName: {
type: String,
default: '',
},
})

const canvasRef = ref(null)
const framerateData = ref([])
const bitrateData = ref([])
let animationFrameId = null
let intervalId = null
let bitrate = 0
// cumulative values
let packetsLost = 0
let packetsReceived = 0
let totalProcessingDelay = 0
let nackCount = 0
let pliCount = 0
let firCount = 0
let framesReceived = 0
let connectionLost = false

let processingDelayDelta = 0
let freezes = 0
let frozenTime = 0
let framedrops = 0

let packetLossPercentage = 0
let framerate = 0
let videoHeight = 0

const maxDataPoints = 100
let maxBitrateReceived = 1000 // max bitrate received, used for scaling the plot
let maxFramerateReceived = 30 // max framerate received, used for scaling the plot
let absoluteMaxFrameRate = 120 // Absolute maximum framerate, used for dealing with outliers

const plotHeight = 60 // Height of the plot area
/**
* Normalize the value to fit the plot area
* @param {number} value - The current value
* @param {number} max - The maximum value currently in the plot
* @returns {number} The normalized value
*/
function normalizeValue(value: number, max: number): number {
return (value / max) * plotHeight
}

/**
* Draw the line plots and stats
*/
function draw(): void {
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
const { width, height } = props

ctx.clearRect(0, 0, width, height)

// Draw bitrate plot
ctx.strokeStyle = 'rgb(255, 165, 0)' // Orange
ctx.lineWidth = 1
ctx.beginPath()
for (let i = 0; i < bitrateData.value.length; i++) {
const x = (i / (maxDataPoints - 1)) * width
const y = height - normalizeValue(bitrateData.value[i], maxBitrateReceived)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()

// Draw framerate plot
ctx.strokeStyle = 'rgb(0, 255, 0)' // Green
ctx.beginPath()
for (let i = 0; i < framerateData.value.length; i++) {
const x = (i / (maxDataPoints - 1)) * width
const y = height - normalizeValue(framerateData.value[i], maxFramerateReceived)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()

// Print text stats
const color = connectionLost ? 'red' : 'white'
const stats = [
{ label: 'Stream', value: props.streamName, color: color },
{ label: 'Size', value: videoHeight ? `${videoHeight}p` : 'N/A', color: color },
{ label: 'Packets Lost', value: `${packetsLost} (${packetLossPercentage.toFixed(0)}%)`, color: color },
{ label: 'Frame drops', value: framedrops, color: color },
{ label: 'Nack', value: nackCount, color: color },
{ label: 'Pli', value: pliCount, color: color },
{ label: 'Fir', value: firCount, color: color },
{ label: 'Processing ', value: `${processingDelayDelta.toFixed(0)}ms`, color: color },
{ label: 'Freezes', value: `${freezes}(${frozenTime.toFixed(1)}s)`, color: color },
{ label: 'Bitrate', value: `${bitrate.toFixed(0)}kbps`, color: 'rgb(255, 165, 0)' },
{ label: 'FPS', value: framerate.toFixed(2), color: 'rgb(0, 255, 0)' },
]

ctx.font = '10px Arial'
stats.forEach((stat, index) => {
ctx.fillStyle = stat.color
ctx.fillText(`${stat.label}: ${stat.value}`, 5, 12 + index * 12)
})

animationFrameId = requestAnimationFrame(draw)
}

const webrtcStats = new WebRTCStats({ getStatsInterval: 100 })

/**
* Draws the lines and updates the stats
*/
function update(): void {
framerateData.value.push(framerate)
bitrateData.value.push(bitrate)
if (framerateData.value.length > maxDataPoints) framerateData.value.shift()
if (bitrateData.value.length > maxDataPoints) bitrateData.value.shift()

// Update max values
maxBitrateReceived = Math.max(maxBitrateReceived, ...bitrateData.value)
maxFramerateReceived = Math.max(maxFramerateReceived, ...framerateData.value)
if (maxFramerateReceived > absoluteMaxFrameRate) maxFramerateReceived = absoluteMaxFrameRate
}

watch(videoStore.activeStreams, (streams): void => {
Object.keys(streams).forEach((streamName) => {
if (streamName !== props.streamName) return
const session = streams[streamName]?.webRtcManager.session
if (!session || !session.peerConnection) return
if (webrtcStats.peersToMonitor[session.consumerId]) return
webrtcStats.addConnection({
pc: session.peerConnection,
peerId: session.consumerId,
connectionId: session.id,
remote: false,
})
})
})

onMounted(() => {
intervalId = setInterval(update, props.updateInterval)
draw()
webrtcStats.on('stats', (ev: WebRTCStatsEvent) => {
try {
const videoData = ev.data.video.inbound[0]
if (videoData === undefined) return
connectionLost = videoData.bitrate === 0
if (!isNaN(videoData.bitrate)) {
const newBitrate = videoData.bitrate / 1000
bitrate = bitrate * 0.8 + newBitrate * 0.2
}
packetsLost = videoData.packetsLost
nackCount = videoData.nackCount
pliCount = videoData.pliCount
firCount = videoData.firCount
packetsReceived = videoData.packetsReceived
let totalProcessingDelayDelta = videoData.totalProcessingDelay - totalProcessingDelay
let framesDelta = videoData.framesReceived - framesReceived
processingDelayDelta = (1000 * totalProcessingDelayDelta) / framesDelta
framesReceived = videoData.framesReceived
totalProcessingDelay = videoData.totalProcessingDelay
packetLossPercentage = (packetsLost / (packetsLost + packetsReceived)) * 100
freezes = videoData.freezeCount
frozenTime = videoData.totalFreezesDuration
framedrops = videoData.framesDropped
framerate = videoData.framesPerSecond ?? 0
videoHeight = videoData.frameHeight
} catch (e) {
console.error(e)
}
})
})

onUnmounted(() => {
clearInterval(intervalId)
cancelAnimationFrame(animationFrameId)
})
</script>

<style scoped>
.canvas-container {
position: absolute;
top: 50px;
left: 10px;
background-color: rgba(0, 0, 0, 0.5);
z-index: 2;
}
</style>
10 changes: 10 additions & 0 deletions src/components/widgets/VideoPlayer.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<div ref="videoWidget" class="video-widget">
<statsForNerds v-if="widget.options.statsForNerds" :stream-name="externalStreamId" />
<div v-if="nameSelectedStream === undefined" class="no-video-alert">
<span>No video stream selected.</span>
</div>
Expand Down Expand Up @@ -88,6 +89,13 @@
:color="widget.options.flipVertically ? 'white' : undefined"
hide-details
/>
<v-switch
v-model="widget.options.statsForNerds"
class="my-1"
label="Stats for nerds"
:color="widget.options.statsForNerds ? 'white' : undefined"
hide-details
/>
<div class="flex-wrap justify-center d-flex ga-5">
<v-btn prepend-icon="mdi-file-rotate-left" variant="outlined" @click="rotateVideo(-90)"> Rotate Left</v-btn>
<v-btn prepend-icon="mdi-file-rotate-right" variant="outlined" @click="rotateVideo(+90)"> Rotate Right</v-btn>
Expand All @@ -101,6 +109,7 @@
import { storeToRefs } from 'pinia'
import { computed, onBeforeMount, onBeforeUnmount, ref, toRefs, watch } from 'vue'

import StatsForNerds from '@/components/VideoPlayerStatsForNerds.vue'
import { isEqual } from '@/libs/utils'
import { useAppInterfaceStore } from '@/stores/appInterface'
import { useVideoStore } from '@/stores/video'
Expand Down Expand Up @@ -134,6 +143,7 @@ onBeforeMount(() => {
flipHorizontally: false,
flipVertically: false,
rotationAngle: 0,
statsForNerds: false,
internalStreamName: undefined as string | undefined,
}
widget.value.options = Object.assign({}, defaultOptions, widget.value.options)
Expand Down
Loading