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

Improve video recording management #655

Merged
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
92 changes: 22 additions & 70 deletions src/components/mini-widgets/MiniVideoRecorder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<button
class="flex items-center p-3 mx-2 font-medium transition-all rounded-md shadow-md w-fit text-uppercase hover:bg-slate-100"
:class="{ 'bg-slate-200 opacity-30 pointer-events-none': isLoadingStream }"
@click=";[startRecording(), (isStreamSelectDialogOpen = false)]"
@click="startRecording"
>
<span>Record</span>
<v-icon v-if="isLoadingStream" class="m-2 animate-spin">mdi-loading</v-icon>
Expand All @@ -54,21 +54,16 @@

<script setup lang="ts">
import { useMouseInElement, useTimestamp } from '@vueuse/core'
import { format, intervalToDuration } from 'date-fns'
import { saveAs } from 'file-saver'
import fixWebmDuration from 'fix-webm-duration'
import { intervalToDuration } from 'date-fns'
import { storeToRefs } from 'pinia'
import Swal, { type SweetAlertResult } from 'sweetalert2'
import { computed, onBeforeMount, onBeforeUnmount, ref, toRefs, watch } from 'vue'

import { datalogger } from '@/libs/sensors-logging'
import { isEqual } from '@/libs/utils'
import { useMissionStore } from '@/stores/mission'
import { useVideoStore } from '@/stores/video'
import type { MiniWidget } from '@/types/miniWidgets'

const videoStore = useVideoStore()
const { missionName } = useMissionStore()

const props = defineProps<{
/**
Expand All @@ -80,19 +75,13 @@ const miniWidget = toRefs(props).miniWidget

const nameSelectedStream = ref<string | undefined>()
const { namesAvailableStreams } = storeToRefs(videoStore)
const mediaRecorder = ref<MediaRecorder>()
const recorderWidget = ref()
const { isOutside } = useMouseInElement(recorderWidget)
const isStreamSelectDialogOpen = ref(false)
const isLoadingStream = ref(false)
const timeRecordingStart = ref(new Date())
const timeNow = useTimestamp({ interval: 100 })
const mediaStream = ref<MediaStream | undefined>()

const isRecording = computed(() => {
return mediaRecorder.value !== undefined && mediaRecorder.value.state === 'recording'
})

onBeforeMount(async () => {
// Set initial widget options if they don't exist
if (Object.keys(miniWidget.value.options).length === 0) {
Expand All @@ -109,69 +98,38 @@ watch(nameSelectedStream, () => {
})

const toggleRecording = async (): Promise<void> => {
if (nameSelectedStream.value === undefined) {
Swal.fire({ text: 'No stream selected. Please choose one before continuing.', icon: 'error' })
return
}
if (isRecording.value) {
stopRecording()
videoStore.stopRecording(nameSelectedStream.value)
return
}
// Open dialog so user can choose the stream which will be recorded
isStreamSelectDialogOpen.value = true
}

const startRecording = async (): Promise<SweetAlertResult | void> => {
if (namesAvailableStreams.value.isEmpty()) {
return Swal.fire({ text: 'No streams available.', icon: 'error' })
}
const startRecording = (): void => {
if (nameSelectedStream.value === undefined) {
if (namesAvailableStreams.value.length === 1) {
await updateCurrentStream(namesAvailableStreams.value[0])
} else {
return Swal.fire({ text: 'No stream selected. Please choose one before continuing.', icon: 'error' })
}
}
if (mediaStream.value === undefined) {
return Swal.fire({ text: 'Media stream not defined.', icon: 'error' })
}
if (!mediaStream.value.active) {
return Swal.fire({ text: 'Media stream not yet active. Wait a second and try again.', icon: 'error' })
}

timeRecordingStart.value = new Date()
const fileName = `${missionName || 'Cockpit'} (${format(timeRecordingStart.value, 'LLL dd, yyyy - HH꞉mm꞉ss O')})`
mediaRecorder.value = new MediaRecorder(mediaStream.value)
if (!datalogger.logging()) {
datalogger.startLogging()
}
const videoTrack = mediaStream.value.getVideoTracks()[0]
const vWidth = videoTrack.getSettings().width || 1920
const vHeight = videoTrack.getSettings().height || 1080
mediaRecorder.value.start(1000)
let chunks: Blob[] = []
mediaRecorder.value.ondataavailable = async (e) => {
chunks.push(e.data)
await videoStore.videoRecoveryDB.setItem(fileName, chunks)
}

mediaRecorder.value.onstop = () => {
const blob = new Blob(chunks, { type: 'video/webm' })
const videoTelemetryLog = datalogger.getSlice(datalogger.currentCockpitLog, timeRecordingStart.value, new Date())
const assLog = datalogger.toAssOverlay(videoTelemetryLog, vWidth, vHeight, timeRecordingStart.value.getTime())
var logBlob = new Blob([assLog], { type: 'text/plain' })
fixWebmDuration(blob, Date.now() - timeRecordingStart.value.getTime()).then((fixedBlob) => {
saveAs(fixedBlob, `${fileName}.webm`)
saveAs(logBlob, `${fileName}.ass`)
videoStore.videoRecoveryDB.removeItem(fileName)
})
chunks = []
mediaRecorder.value = undefined
Swal.fire({ text: 'No stream selected.', icon: 'error' })
return
}
videoStore.startRecording(nameSelectedStream.value)
isStreamSelectDialogOpen.value = false
}

const stopRecording = (): void => {
mediaRecorder.value?.stop()
}
const isRecording = computed(() => {
if (nameSelectedStream.value === undefined) return false
return videoStore.isRecording(nameSelectedStream.value)
})

const timePassedString = computed(() => {
const duration = intervalToDuration({ start: timeRecordingStart.value, end: timeNow.value })
if (nameSelectedStream.value === undefined) return '00:00:00'
const timeRecordingStart = videoStore.getStreamData(nameSelectedStream.value)?.timeRecordingStart
if (timeRecordingStart === undefined) return '00:00:00'

const duration = intervalToDuration({ start: timeRecordingStart, end: timeNow.value })
const durationHours = duration.hours?.toFixed(0).length === 1 ? `0${duration.hours}` : duration.hours
const durationMinutes = duration.minutes?.toFixed(0).length === 1 ? `0${duration.minutes}` : duration.minutes
const durationSeconds = duration.seconds?.toFixed(0).length === 1 ? `0${duration.seconds}` : duration.seconds
Expand All @@ -196,16 +154,10 @@ const updateCurrentStream = async (streamName: string | undefined): Promise<Swee
return Swal.fire({ text: 'Could not load media stream.', icon: 'error' })
}
}
miniWidget.value.options.streamName = nameSelectedStream.value
miniWidget.value.options.streamName = streamName
}

const streamConnectionRoutine = setInterval(() => {
// If the video player widget is cold booted, assign the first stream to it
if (miniWidget.value.options.streamName === undefined && !namesAvailableStreams.value.isEmpty()) {
miniWidget.value.options.streamName = namesAvailableStreams.value[0]
nameSelectedStream.value = miniWidget.value.options.streamName
}

const updatedMediaStream = videoStore.getMediaStream(miniWidget.value.options.streamName)
// If the widget is not connected to the MediaStream, try to connect it
if (!isEqual(updatedMediaStream, mediaStream.value)) {
Expand Down
122 changes: 117 additions & 5 deletions src/stores/video.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { useStorage } from '@vueuse/core'
import { format } from 'date-fns'
import { saveAs } from 'file-saver'
import fixWebmDuration from 'fix-webm-duration'
import localforage from 'localforage'
import { defineStore } from 'pinia'
import Swal from 'sweetalert2'
import { computed, ref, watch } from 'vue'
import adapter from 'webrtc-adapter'

import { WebRTCManager } from '@/composables/webRTC'
import { datalogger } from '@/libs/sensors-logging'
import { isEqual } from '@/libs/utils'
import { useMainVehicleStore } from '@/stores/mainVehicle'
import { useMissionStore } from '@/stores/mission'
import type { StreamData } from '@/types/video'

export const useVideoStore = defineStore('video', () => {
const { missionName } = useMissionStore()
const { rtcConfiguration, webRTCSignallingURI } = useMainVehicleStore()
console.debug('[WebRTC] Using webrtc-adapter for', adapter.browserDetails)

Expand Down Expand Up @@ -47,8 +52,7 @@ export const useVideoStore = defineStore('video', () => {
console.log(`New stream for '${streamName}':`)
console.log(JSON.stringify(updatedStream, null, 2))
activateStream(streamName)
// @ts-ignore
activeStreams.value[streamName].stream = updatedStream
activeStreams.value[streamName]!.stream = updatedStream
})
}, 300)

Expand All @@ -63,23 +67,127 @@ export const useVideoStore = defineStore('video', () => {
const webRtcManager = new WebRTCManager(webRTCSignallingURI.val, rtcConfiguration)
const { mediaStream } = webRtcManager.startStream(stream, allowedIceIps)
activeStreams.value[streamName] = {
// @ts-ignore: This is actually not reactive
stream: stream,
webRtcManager: webRtcManager,
// @ts-ignore: This is actually not reactive
mediaStream: mediaStream,
mediaRecorder: undefined,
timeRecordingStart: undefined,
}
}

/**
* bandwith or stress the stream provider more than we need to.
* Get all data related to a given stream, if available
* @param {string} streamName - Name of the stream
* @returns {StreamData | undefined} The StreamData object, if available
*/
const getStreamData = (streamName: string): StreamData | undefined => {
if (activeStreams.value[streamName] === undefined) {
activateStream(streamName)
}
return activeStreams.value[streamName]
}

/**
* Get the MediaStream object related to a given stream, if available
* @param {string} streamName - Name of the stream
* @returns {MediaStream | undefined} MediaStream that is running, if available
*/
const getMediaStream = (streamName: string): MediaStream | undefined => {
if (activeStreams.value[streamName] === undefined) {
activateStream(streamName)
}
// @ts-ignore
return activeStreams.value[streamName].mediaStream
return activeStreams.value[streamName]!.mediaStream
}

/**
* Wether or not the stream is currently being recorded
* @param {string} streamName - Name of the stream
* @returns {boolean}
*/
const isRecording = (streamName: string): boolean => {
if (activeStreams.value[streamName] === undefined) activateStream(streamName)

return (
activeStreams.value[streamName]!.mediaRecorder !== undefined &&
activeStreams.value[streamName]!.mediaRecorder!.state === 'recording'
)
}

/**
* Stop recording the stream
* @param {string} streamName - Name of the stream
*/
const stopRecording = (streamName: string): void => {
if (activeStreams.value[streamName] === undefined) activateStream(streamName)

activeStreams.value[streamName]!.mediaRecorder!.stop()
}

/**
* Start recording the stream
* @param {string} streamName - Name of the stream
*/
const startRecording = (streamName: string): void => {
if (activeStreams.value[streamName] === undefined) activateStream(streamName)

if (namesAvailableStreams.value.isEmpty()) {
Swal.fire({ text: 'No streams available.', icon: 'error' })
return
}

if (activeStreams.value[streamName]!.mediaStream === undefined) {
Swal.fire({ text: 'Media stream not defined.', icon: 'error' })
return
}
if (!activeStreams.value[streamName]!.mediaStream!.active) {
Swal.fire({ text: 'Media stream not yet active. Wait a second and try again.', icon: 'error' })
return
}

activeStreams.value[streamName]!.timeRecordingStart = new Date()
const streamData = activeStreams.value[streamName] as StreamData
const fileName = `${missionName || 'Cockpit'} (${format(
streamData.timeRecordingStart!,
'LLL dd, yyyy - HH꞉mm꞉ss O'
)})`
activeStreams.value[streamName]!.mediaRecorder = new MediaRecorder(streamData.mediaStream!)
if (!datalogger.logging()) {
datalogger.startLogging()
}
const videoTrack = streamData.mediaStream!.getVideoTracks()[0]
const vWidth = videoTrack.getSettings().width || 1920
const vHeight = videoTrack.getSettings().height || 1080
activeStreams.value[streamName]!.mediaRecorder!.start(1000)
let chunks: Blob[] = []
activeStreams.value[streamName]!.mediaRecorder!.ondataavailable = async (e) => {
chunks.push(e.data)
await videoRecoveryDB.setItem(fileName, chunks)
}

activeStreams.value[streamName]!.mediaRecorder!.onstop = () => {
const blob = new Blob(chunks, { type: 'video/webm' })
const videoTelemetryLog = datalogger.getSlice(
datalogger.currentCockpitLog,
streamData.timeRecordingStart!,
new Date()
)
const assLog = datalogger.toAssOverlay(
videoTelemetryLog,
vWidth,
vHeight,
streamData.timeRecordingStart!.getTime()
)
const logBlob = new Blob([assLog], { type: 'text/plain' })
fixWebmDuration(blob, Date.now() - streamData.timeRecordingStart!.getTime()).then((fixedBlob) => {
saveAs(fixedBlob, `${fileName}.webm`)
saveAs(logBlob, `${fileName}.ass`)
videoRecoveryDB.removeItem(fileName)
})
chunks = []
activeStreams.value[streamName]!.mediaRecorder = undefined
}
}

// Offer download of backuped videos
Expand Down Expand Up @@ -155,5 +263,9 @@ export const useVideoStore = defineStore('video', () => {
namesAvailableStreams,
videoRecoveryDB,
getMediaStream,
getStreamData,
isRecording,
stopRecording,
startRecording,
}
})
14 changes: 10 additions & 4 deletions src/types/video.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { Ref } from 'vue'

import { WebRTCManager } from '@/composables/webRTC'
import type { Stream } from '@/libs/webrtc/signalling_protocol'

Expand All @@ -10,13 +8,21 @@ export interface StreamData {
/**
* The actual WebRTC stream
*/
stream: Ref<Stream | undefined>
stream: Stream | undefined
/**
* The responsible for its management
*/
webRtcManager: WebRTCManager
/**
* MediaStream object, if WebRTC stream is chosen
*/
mediaStream: Ref<MediaStream | undefined>
mediaStream: MediaStream | undefined
/**
* MediaRecorder object for that stream
*/
mediaRecorder: MediaRecorder | undefined
/**
* Date object with info on when a recording was started, if so
*/
timeRecordingStart: Date | undefined
}