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

refactor(webcam): refactor Mjpegstreamer-Adaptive Webcam mode #1994

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
271 changes: 146 additions & 125 deletions src/components/webcams/streamers/MjpegstreamerAdaptive.vue
Original file line number Diff line number Diff line change
@@ -1,55 +1,58 @@
<template>
<div style="position: relative" class="d-flex justify-center">
<div v-if="!isLoaded" class="text-center py-5">
<v-progress-circular indeterminate color="primary" />
</div>
<canvas
<div v-observe-visibility="viewportVisibilityChanged" class="d-flex justify-center webcamBackground">
<img
v-show="status === 'connected'"
ref="image"
v-observe-visibility="viewportVisibilityChanged"
width="600"
height="400"
class="webcamImage"
:style="webcamStyle"
:class="'webcamImage ' + (isLoaded ? '' : 'hiddenWebcam')" />
<span v-if="isLoaded && showFpsCounter" class="webcamFpsOutput">
:alt="camSettings.name"
src="#"
@error="onError"
@load="onLoad" />
<span v-if="status === 'connected' && showFpsCounter" class="webcamFpsOutput">
{{ $t('Panels.WebcamPanel.FPS') }}: {{ fpsOutput }}
</span>
<v-row v-if="status !== 'connected'">
<v-col class="_webcam_mjpegstreamer_output text-center d-flex flex-column justify-center align-center">
<v-progress-circular v-if="status === 'connecting'" indeterminate color="primary" class="mb-3" />
<span class="mt-3">{{ statusMessage }}</span>
</v-col>
</v-row>
</div>
</template>

<script lang="ts">
import Component from 'vue-class-component'
import { Mixins, Prop } from 'vue-property-decorator'
import { Mixins, Prop, Ref, Watch } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import { GuiWebcamStateWebcam } from '@/store/gui/webcams/types'
import WebcamMixin from '@/components/mixins/webcam'

@Component
export default class MjpegstreamerAdaptive extends Mixins(BaseMixin, WebcamMixin) {
private refresh = Math.ceil(Math.random() * Math.pow(10, 12))
private isVisible = true
private isVisibleDocument = true
private isVisibleViewport = false
private isLoaded = true
// eslint-disable-next-line no-undef
private timer: NodeJS.Timeout | undefined = undefined

private request_start_time = performance.now()
private start_time = performance.now()
private time = 0
private request_time = 0
private time_smoothing = 0.6
private request_time_smoothing = 0.1
private currentFPS = 0
private aspectRatio: null | number = null

declare $refs: {
image: any
}
isVisibleDocument = true
isVisibleViewport = false
status: string = 'connecting'
statusMessage: string = ''

timer: number | null = null
request_start_time = performance.now()
time = 0
request_time = 0
request_time_smoothing = 0.2

currentFPS: number | null = null
fpsTimer: number | null = null
frames = 0

aspectRatio: null | number = null

@Prop({ required: true }) declare camSettings: GuiWebcamStateWebcam
@Prop({ default: null }) readonly printerUrl!: string | null
@Prop({ default: true }) declare showFps: boolean

@Ref('image') readonly image!: HTMLImageElement

get webcamStyle() {
const output = {
transform: this.generateTransform(
Expand All @@ -67,10 +70,19 @@ export default class MjpegstreamerAdaptive extends Mixins(BaseMixin, WebcamMixin
output.maxWidth = (window.innerHeight - 155) * this.aspectRatio + 'px'
}

if (this.aspectRatio && [90, 270].includes(this.camSettings.rotation)) {
if (output.transform === 'none') output.transform = ''

const scale = 1 / this.aspectRatio
output.transform += ' rotate(' + this.camSettings.rotation + 'deg) scale(' + scale + ')'
}

return output
}

get fpsOutput() {
if (this.currentFPS === null) return '--'

return this.currentFPS < 10 ? '0' + this.currentFPS.toString() : this.currentFPS
}

Expand All @@ -80,152 +92,161 @@ export default class MjpegstreamerAdaptive extends Mixins(BaseMixin, WebcamMixin
return !(this.camSettings.extra_data?.hideFps ?? false)
}

get rotate() {
return [90, 270].includes(this.camSettings.rotation ?? 0)
}

get url() {
return this.convertUrl(this.camSettings?.snapshot_url, this.printerUrl)
}

refreshFrame() {
if (!this.isVisible) return
get isVisible() {
return this.isVisibleDocument && this.isVisibleViewport
}

this.refresh = new Date().getTime()
this.setFrame()
mounted() {
document.addEventListener('visibilitychange', this.documentVisibilityChanged)
}

async setFrame() {
let url = new URL(this.url)
url.searchParams.append('bypassCache', this.refresh.toString())
beforeDestroy() {
document.removeEventListener('visibilitychange', this.documentVisibilityChanged)
this.stopStream()
}

this.request_start_time = performance.now()
this.currentFPS = this.time > 0 ? Math.round(1000 / this.time) : 0

let canvas = this.$refs.image
if (canvas) {
const ctx = canvas.getContext('2d')
const frame: any = await this.loadImage(url.toString())

this.aspectRatio = frame.naturalWidth / frame.naturalHeight
if (this.rotate) this.aspectRatio = 1 / this.aspectRatio

// set canvas sizes
canvas.width = canvas.clientWidth
canvas.height = canvas.clientWidth / this.aspectRatio

if (this.rotate) {
const scale = canvas.height / frame.width
const x = canvas.width / 2
const y = canvas.height / 2
ctx.translate(x, y)
ctx.rotate((this.camSettings.rotation * Math.PI) / 180)
await ctx?.drawImage(
frame,
(-frame.width / 2) * scale,
(-frame.height / 2) * scale,
frame.width * scale,
frame.height * scale
)
ctx.rotate(-((this.camSettings.rotation * Math.PI) / 180))
ctx.translate(-x, -y)
} else await ctx?.drawImage(frame, 0, 0, frame.width, frame.height, 0, 0, canvas.width, canvas.height)

this.isLoaded = true
documentVisibilityChanged() {
const visibility = document.visibilityState
this.isVisibleDocument = visibility === 'visible'
}

viewportVisibilityChanged(newVal: boolean) {
this.isVisibleViewport = newVal
}

@Watch('isVisible', { immediate: true })
isVisibleChanged(newVal: boolean) {
if (newVal) {
this.startStream()
return
}

this.$nextTick(() => {
this.onLoad()
})
this.stopStream()
}

refreshFrame() {
if (!this.isVisible) return

if (this.timer !== null) {
window.clearTimeout(this.timer)
this.timer = null
}

const url = new URL(this.url)
url.searchParams.append('bypassCache', new Date().getTime().toString())
this.image.src = url.toString()
this.request_start_time = performance.now()
}

onLoad() {
this.isLoaded = true
if (this.status !== 'connected') {
this.status = 'connected'
this.statusMessage = ''
}
this.frames++

const targetFps = this.camSettings.target_fps || 10
const end_time = performance.now()
const current_time = end_time - this.start_time
this.time = this.time * this.time_smoothing + current_time * (1.0 - this.time_smoothing)
this.start_time = end_time
if (this.aspectRatio === null) {
this.aspectRatio = this.image.naturalWidth / this.image.naturalHeight
}

const targetFps = this.camSettings.target_fps || 10
const target_time = 1000 / targetFps

const current_request_time = performance.now() - this.request_start_time
this.request_time =
this.request_time * this.request_time_smoothing + current_request_time * (1.0 - this.request_time_smoothing)
this.request_time * this.request_time_smoothing + current_request_time * (1 - this.request_time_smoothing)
const timeout = Math.max(0, target_time - this.request_time)

this.$nextTick(() => {
this.timer = setTimeout(this.refreshFrame, timeout)
})
this.timer = window.setTimeout(this.refreshFrame, timeout)
}

loadImage(url: string) {
return new Promise((r) => {
let image = new Image()
image.onload = () => r(image)
image.onerror = () => setTimeout(this.refreshFrame, 1000)
image.src = url
})
}
onError() {
this.status = 'error'
this.statusMessage = this.$t('Panels.WebcamPanel.ErrorWhileConnecting', { url: this.url }).toString()

mounted() {
document.addEventListener('visibilitychange', this.documentVisibilityChanged)
this.refreshFrame()
}
if (this.timer !== null) return

beforeDestroy() {
document.removeEventListener('visibilitychange', this.documentVisibilityChanged)
this.timer = window.setTimeout(this.refreshFrame, 1000)
}

documentVisibilityChanged() {
const visibility = document.visibilityState
this.isVisibleDocument = visibility === 'visible'
if (!this.isVisibleDocument) this.stopStream()
this.visibilityChanged()
startStream() {
// is not visible or already streaming
if (!this.isVisible) return

if (this.status !== 'connected') {
this.status = 'connecting'
this.statusMessage = this.$t('Panels.WebcamPanel.ConnectingTo', { url: this.url }).toString()
}

this.clearTimers()

this.fpsTimer = window.setInterval(() => {
this.currentFPS = this.frames
this.frames = 0
}, 1000)

this.refreshFrame()
}

viewportVisibilityChanged(newVal: boolean) {
this.isVisibleViewport = newVal
this.visibilityChanged()
stopStream() {
this.clearTimers()
}

visibilityChanged() {
if (this.isVisibleViewport && this.isVisibleDocument) {
this.startStream()
return
clearTimers() {
if (this.timer) {
window.clearTimeout(this.timer)
this.timer = null
}

this.stopStream()
if (this.fpsTimer) {
window.clearTimeout(this.fpsTimer)
this.fpsTimer = null
this.frames = 0
}
}

startStream() {
if (this.isVisible) return
this.isVisible = true
this.refreshFrame()
}
@Watch('camSettings', { deep: true })
camSettingsChanged() {
this.aspectRatio = null
this.stopStream()

stopStream() {
this.isVisible = false
clearTimeout(this.timer)
this.timer = undefined
this.status = 'connecting'

this.startStream()
}
}
</script>

<style scoped>
.webcamBackground {
position: relative;
background: rgba(0, 0, 0, 0.8);
}

.webcamImage {
width: 100%;
}

._webcam_mjpegstreamer_output {
aspect-ratio: calc(3 / 2);
}

.webcamFpsOutput {
display: inline-block;
position: absolute;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
padding: 3px 10px;
border-top-left-radius: 5px;
background: rgba(0, 0, 0, 0.8);
}

html.theme--light .webcamBackground {
background: rgba(255, 255, 255, 0.7);
}

html.theme--light .webcamFpsOutput {
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,8 @@
},
"WebcamPanel": {
"All": "All",
"ConnectingTo": "Connecting to {url}",
"ErrorWhileConnecting": "Error while connecting to {url}",
"FPS": "FPS",
"Headline": "Webcam",
"NoWebcam": "No webcam available. Add a webcam under \"Interface Settings\" -> \"Webcams\".",
Expand Down
Loading