Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaellehmkuhl committed Sep 23, 2024
1 parent e4a7f22 commit 5ecff0a
Show file tree
Hide file tree
Showing 11 changed files with 146 additions and 47 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
"serve": "vite preview",
"test:ci": "vitest --coverage --run",
"test:unit": "vitest",
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false"
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"build:electron": "cross-env BUILD_TARGET=electron vite build",
"build:browser": "vite build"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0",
Expand Down
6 changes: 3 additions & 3 deletions src/components/VideoLibraryModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -889,11 +889,11 @@ const fetchVideosAndLogData = async (): Promise<void> => {
const logFileOperations: Promise<VideoLibraryLogFile>[] = []
// Fetch processed videos and logs
await videoStore.videoStoringDB.iterate((value, key) => {
await videoStore.videoStorage.iterate((value, key) => {
if (videoStore.isVideoFilename(key)) {
videoFilesOperations.push(
(async () => {
const videoBlob = await videoStore.videoStoringDB.getItem<Blob>(key)
const videoBlob = await videoStore.videoStorage.getItem<Blob>(key)
let url = ''
let isProcessed = true
if (videoBlob instanceof Blob) {
Expand All @@ -910,7 +910,7 @@ const fetchVideosAndLogData = async (): Promise<void> => {
if (key.endsWith('.ass')) {
logFileOperations.push(
(async () => {
const videoBlob = await videoStore.videoStoringDB.getItem<Blob>(key)
const videoBlob = await videoStore.videoStorage.getItem<Blob>(key)
let url = ''
if (videoBlob instanceof Blob) {
url = URL.createObjectURL(videoBlob)
Expand Down
2 changes: 1 addition & 1 deletion src/components/mini-widgets/MiniVideoRecorder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ watch(nameSelectedStream, () => {
// Fetch number of temporary videos on storage
const fetchNumberOfTempVideos = async (): Promise<void> => {
const nProcessedVideos = (await videoStore.videoStoringDB.keys()).filter((k) => videoStore.isVideoFilename(k)).length
const nProcessedVideos = (await videoStore.videoStorage.keys()).filter((k) => videoStore.isVideoFilename(k)).length
const nFailedUnprocessedVideos = Object.keys(videoStore.keysFailedUnprocessedVideos).length
numberOfVideosOnDB.value = nProcessedVideos + nFailedUnprocessedVideos
}
Expand Down
62 changes: 62 additions & 0 deletions src/libs/electron/video.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { app } from 'electron'
import fs from 'fs/promises'
import { dirname, join } from 'path'

import { isElectron } from '@/libs/utils'
import { StorageDB } from '@/types/video'

// Create a new storage interface for filesystem
const cockpitVideosDir = join(app.getPath('userData'), 'Cockpit', 'videos')
const filesystemOnlyInElectronErrorMsg = 'Filesystem storage is only available in Electron'

export const filesystemStorage: StorageDB = {
async setItem(key: string, value: Blob): Promise<void> {
if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg)
const filePath = join(cockpitVideosDir, key)
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(filePath, Buffer.from(await value.arrayBuffer()))
},
async getItem(key: string): Promise<Blob | null> {
if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg)
const filePath = join(cockpitVideosDir, key)
try {
const buffer = await fs.readFile(filePath)
return new Blob([buffer])
} catch (error) {
if (error.code === 'ENOENT') return null
throw error
}
},
async removeItem(key: string): Promise<void> {
if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg)
const filePath = join(cockpitVideosDir, key)
await fs.unlink(filePath)
},
async clear(): Promise<void> {
if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg)
throw new Error(
`Clear functionality is not available in the filesystem storage, so we don't risk losing important data. If you
want to clear the video storage, please delete the "videos" folder inside the Cockpit folder in your user data
directory manually.`
)
},
async keys(): Promise<string[]> {
if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg)
const dirPath = cockpitVideosDir
try {
return await fs.readdir(dirPath)
} catch (error) {
if (error.code === 'ENOENT') return []
throw error
}
},
async iterate(callback: (value: unknown, key: string, iterationNumber: number) => void): Promise<void> {
if (!isElectron()) throw new Error(filesystemOnlyInElectronErrorMsg)
const keys = await this.keys()
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const value = await this.getItem(key)
callback(value, key, i)
}
},
}
8 changes: 8 additions & 0 deletions src/libs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,11 @@ export const reloadCockpit = (timeout = 500): void => {
showDialog({ message: restartMessage, variant: 'info', timer: timeout })
setTimeout(() => location.reload(), timeout)
}

/**
* Checks if the current environment is Electron
* @returns {boolean} True if running in Electron, false otherwise
*/
export const isElectron = (): boolean => {
return typeof process !== 'undefined' && process.versions && !!process.versions.electron
}
20 changes: 20 additions & 0 deletions src/libs/videoStorage.browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import localforage from 'localforage'

const tempVideoChunksDB = localforage.createInstance({
driver: localforage.INDEXEDDB,
name: 'Cockpit - Temporary Video',
storeName: 'cockpit-temp-video-db',
version: 1.0,
description: 'Database for storing the chunks of an ongoing recording, to be merged afterwards.',
})

const videoStoringDB = localforage.createInstance({
driver: localforage.INDEXEDDB,
name: 'Cockpit - Video Recovery',
storeName: 'cockpit-video-recovery-db',
version: 1.0,
description: 'Local backups of Cockpit video recordings to be retrieved in case of failure.',
})

export const videoStorage = videoStoringDB
export const tempVideoStorage = tempVideoChunksDB
4 changes: 4 additions & 0 deletions src/libs/videoStorage.electron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { filesystemStorage } from './electron/video'

export const videoStorage = filesystemStorage
export const tempVideoStorage = filesystemStorage
64 changes: 23 additions & 41 deletions src/stores/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useDebounceFn, useStorage, useThrottleFn, useTimestamp } from '@vueuse/
import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js'
import { differenceInSeconds, format } from 'date-fns'
import { saveAs } from 'file-saver'
import localforage from 'localforage'
import { defineStore } from 'pinia'
import { v4 as uuid } from 'uuid'
import { computed, ref, watch } from 'vue'
Expand All @@ -17,6 +16,7 @@ import { getIpsInformationFromVehicle } from '@/libs/blueos'
import { availableCockpitActions, registerActionCallback } from '@/libs/joystick/protocols/cockpit-actions'
import { datalogger } from '@/libs/sensors-logging'
import { isEqual, sleep } from '@/libs/utils'
import { tempVideoStorage, videoStorage } from '@/libs/videoStorage'
import { useMainVehicleStore } from '@/stores/mainVehicle'
import { useMissionStore } from '@/stores/mission'
import { Alert, AlertLevel } from '@/types/alert'
Expand Down Expand Up @@ -274,7 +274,7 @@ export const useVideoStore = defineStore('video', () => {

let recordingHash = ''
let refreshHash = true
const namesCurrentChunksOnDB = await tempVideoChunksDB.keys()
const namesCurrentChunksOnDB = await tempVideoStorage.keys()
while (refreshHash) {
recordingHash = uuid().slice(0, 8)
refreshHash = namesCurrentChunksOnDB.some((chunkName) => chunkName.includes(recordingHash))
Expand Down Expand Up @@ -363,7 +363,7 @@ export const useVideoStore = defineStore('video', () => {
const chunkName = `${recordingHash}_${chunksCount}`

try {
await tempVideoChunksDB.setItem(chunkName, e.data)
await tempVideoStorage.setItem(chunkName, e.data)
sequentialLostChunks = 0
} catch {
sequentialLostChunks++
Expand All @@ -380,7 +380,7 @@ export const useVideoStore = defineStore('video', () => {
// Gets the thumbnail from the first chunk
if (chunksCount === 0) {
try {
const videoChunk = await tempVideoChunksDB.getItem(chunkName)
const videoChunk = await tempVideoStorage.getItem(chunkName)
if (videoChunk) {
const firstChunkBlob = new Blob([videoChunk as Blob])
const thumbnail = await extractThumbnailFromVideo(firstChunkBlob)
Expand Down Expand Up @@ -429,13 +429,13 @@ export const useVideoStore = defineStore('video', () => {
const discardProcessedFilesFromVideoDB = async (fileNames: string[]): Promise<void> => {
console.debug(`Discarding files from the video recovery database: ${fileNames.join(', ')}`)
for (const filename of fileNames) {
await videoStoringDB.removeItem(filename)
await videoStorage.removeItem(filename)
}
}

const discardUnprocessedFilesFromVideoDB = async (hashes: string[]): Promise<void> => {
for (const hash of hashes) {
await tempVideoChunksDB.removeItem(hash)
await tempVideoStorage.removeItem(hash)
delete unprocessedVideos.value[hash]
}
}
Expand All @@ -455,7 +455,7 @@ export const useVideoStore = defineStore('video', () => {
}

const downloadFiles = async (
db: StorageDB,
db: StorageDB | LocalForage,
keys: string[],
shouldZip = false,
zipFilenamePrefix = 'Cockpit-Video-Files',
Expand Down Expand Up @@ -489,58 +489,40 @@ export const useVideoStore = defineStore('video', () => {
console.debug(`Downloading files from the video recovery database: ${fileNames.join(', ')}`)
if (zipMultipleFiles.value) {
const ZipFilename = fileNames.length > 1 ? 'Cockpit-Video-Recordings' : 'Cockpit-Video-Recording'
await downloadFiles(videoStoringDB, fileNames, true, ZipFilename, progressCallback)
await downloadFiles(videoStorage, fileNames, true, ZipFilename, progressCallback)
} else {
await downloadFiles(videoStoringDB, fileNames)
await downloadFiles(videoStorage, fileNames)
}
}

const downloadTempVideo = async (hashes: string[], progressCallback?: DownloadProgressCallback): Promise<void> => {
console.debug(`Downloading ${hashes.length} video chunks from the temporary database.`)

for (const hash of hashes) {
const fileNames = (await tempVideoChunksDB.keys()).filter((filename) => filename.includes(hash))
const fileNames = (await tempVideoStorage.keys()).filter((filename) => filename.includes(hash))
const zipFilenamePrefix = `Cockpit-Unprocessed-Video-Chunks-${hash}`
await downloadFiles(tempVideoChunksDB, fileNames, true, zipFilenamePrefix, progressCallback)
await downloadFiles(tempVideoStorage, fileNames, true, zipFilenamePrefix, progressCallback)
}
}

// Used to clear the temporary video database
const clearTemporaryVideoDB = async (): Promise<void> => {
await tempVideoChunksDB.clear()
await tempVideoStorage.clear()
}

const temporaryVideoDBSize = async (): Promise<number> => {
let totalSizeBytes = 0
await tempVideoChunksDB.iterate((chunk) => {
await tempVideoStorage.iterate((chunk) => {
totalSizeBytes += (chunk as Blob).size
})
return totalSizeBytes
}

const videoStorageFileSize = async (filename: string): Promise<number | undefined> => {
const file = await videoStoringDB.getItem(filename)
const file = await videoStorage.getItem(filename)
return file ? (file as Blob).size : undefined
}

// Used to store chunks of an ongoing recording, that will be merged into a video file when the recording is stopped
const tempVideoChunksDB = localforage.createInstance({
driver: localforage.INDEXEDDB,
name: 'Cockpit - Temporary Video',
storeName: 'cockpit-temp-video-db',
version: 1.0,
description: 'Database for storing the chunks of an ongoing recording, to be merged afterwards.',
})

// Offer download of backuped videos
const videoStoringDB = localforage.createInstance({
driver: localforage.INDEXEDDB,
name: 'Cockpit - Video Recovery',
storeName: 'cockpit-video-recovery-db',
version: 1.0,
description: 'Local backups of Cockpit video recordings to be retrieved in case of failure.',
})

const updateLastProcessingUpdate = (recordingHash: string): void => {
const info = unprocessedVideos.value[recordingHash]
info.dateLastProcessingUpdate = new Date()
Expand Down Expand Up @@ -592,7 +574,7 @@ export const useVideoStore = defineStore('video', () => {
const dateFinish = new Date(info.dateFinish!)

debouncedUpdateFileProgress(info.fileName, 30, 'Grouping video chunks.')
await tempVideoChunksDB.iterate((videoChunk, chunkName) => {
await tempVideoStorage.iterate((videoChunk, chunkName) => {
if (chunkName.includes(hash)) {
chunks.push({ blob: videoChunk as Blob, name: chunkName })
}
Expand Down Expand Up @@ -632,7 +614,7 @@ export const useVideoStore = defineStore('video', () => {
updateLastProcessingUpdate(hash)

debouncedUpdateFileProgress(info.fileName, 75, `Saving video file.`)
await videoStoringDB.setItem(`${info.fileName}.${extensionContainer || '.webm'}`, durFixedBlob ?? mergedBlob)
await videoStorage.setItem(`${info.fileName}.${extensionContainer || '.webm'}`, durFixedBlob ?? mergedBlob)

updateLastProcessingUpdate(hash)

Expand All @@ -646,7 +628,7 @@ export const useVideoStore = defineStore('video', () => {
const videoTelemetryLog = datalogger.getSlice(telemetryLog, dateStart, dateFinish)
const assLog = datalogger.toAssOverlay(videoTelemetryLog, info.vWidth!, info.vHeight!, dateStart.getTime())
const logBlob = new Blob([assLog], { type: 'text/plain' })
videoStoringDB.setItem(`${info.fileName}.ass`, logBlob)
videoStorage.setItem(`${info.fileName}.ass`, logBlob)

updateLastProcessingUpdate(hash)

Expand All @@ -659,7 +641,7 @@ export const useVideoStore = defineStore('video', () => {

// Remove temp chunks and video metadata from the database
const cleanupProcessedData = async (recordingHash: string): Promise<void> => {
await tempVideoChunksDB.removeItem(recordingHash)
await tempVideoStorage.removeItem(recordingHash)
delete unprocessedVideos.value[recordingHash]
}

Expand Down Expand Up @@ -698,7 +680,7 @@ export const useVideoStore = defineStore('video', () => {
if (keysFailedUnprocessedVideos.value.isEmpty()) return
console.log(`Processing unprocessed videos: ${keysFailedUnprocessedVideos.value.join(', ')}`)

const chunks = await tempVideoChunksDB.keys()
const chunks = await tempVideoStorage.keys()
if (chunks.length === 0) {
discardUnprocessedVideos()
throw new Error('No video recording data found. Discarding leftover info.')
Expand All @@ -725,14 +707,14 @@ export const useVideoStore = defineStore('video', () => {
console.log('Discarding unprocessed videos.')

const keysUnprocessedVideos = includeNotFailed ? keysAllUnprocessedVideos.value : keysFailedUnprocessedVideos.value
const currentChunks = await tempVideoChunksDB.keys()
const currentChunks = await tempVideoStorage.keys()
const chunksUnprocessedVideos = currentChunks.filter((chunkName) => {
return keysUnprocessedVideos.some((key) => chunkName.includes(key))
})

unprocessedVideos.value = {}
for (const chunk of chunksUnprocessedVideos) {
tempVideoChunksDB.removeItem(chunk)
tempVideoStorage.removeItem(chunk)
}
}

Expand Down Expand Up @@ -888,8 +870,8 @@ export const useVideoStore = defineStore('video', () => {
jitterBufferTarget,
zipMultipleFiles,
namesAvailableStreams,
videoStoringDB,
tempVideoChunksDB,
videoStorage,
tempVideoStorage,
streamsCorrespondency,
namessAvailableAbstractedStreams,
externalStreamId,
Expand Down
5 changes: 5 additions & 0 deletions src/types/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ export interface FileDescriptor {

export interface StorageDB {
getItem: (key: string) => Promise<Blob | null | undefined>
setItem: (key: string, value: Blob) => Promise<void>
removeItem: (key: string) => Promise<void>
clear: () => Promise<void>
keys: () => Promise<string[]>
iterate: (callback: (value: unknown, key: string, iterationNumber: number) => void) => Promise<void>
}

export type DownloadProgressCallback = (progress: number, total: number) => Promise<void>
Expand Down
7 changes: 6 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
}
],
"compilerOptions": {
"target": "es2021"
"target": "es2021",
"baseUrl": "./",
"paths": {
"@/libs/videoStorage": ["src/libs/videoStorage.*.ts"],
// ... existing paths ...
}
},
}
Loading

0 comments on commit 5ecff0a

Please sign in to comment.