Skip to content

Commit

Permalink
video-recording: Move recording pipeline to the video store
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaellehmkuhl committed Dec 23, 2023
1 parent 40964b0 commit a496ffe
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 70 deletions.
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
112 changes: 112 additions & 0 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 @@ -67,9 +72,23 @@ export const useVideoStore = defineStore('video', () => {
webRtcManager: webRtcManager,
// @ts-ignore: This is actually not reactive
mediaStream: mediaStream,
mediaRecorder: undefined,
timeRecordingStart: undefined,
}
}

/**
* 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
Expand All @@ -82,6 +101,95 @@ export const useVideoStore = defineStore('video', () => {
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
const videoRecoveryDB = localforage.createInstance({
driver: localforage.INDEXEDDB,
Expand Down Expand Up @@ -155,5 +263,9 @@ export const useVideoStore = defineStore('video', () => {
namesAvailableStreams,
videoRecoveryDB,
getMediaStream,
getStreamData,
isRecording,
stopRecording,
startRecording,
}
})
8 changes: 8 additions & 0 deletions src/types/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,12 @@ export interface StreamData {
* MediaStream object, if WebRTC stream is chosen
*/
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
}

0 comments on commit a496ffe

Please sign in to comment.