Skip to content

Commit

Permalink
video: Use the filesystem to store videos on the electron-based app
Browse files Browse the repository at this point in the history
For that an abstraction layer was created. It coordinates the usage of the IndexedDB for the browser version and the filesystem for the Electron version.
  • Loading branch information
rafaellehmkuhl committed Dec 10, 2024
1 parent cbfc9c5 commit fa42f76
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 51 deletions.
9 changes: 5 additions & 4 deletions src/components/VideoLibraryModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -889,11 +889,12 @@ const fetchVideosAndLogData = async (): Promise<void> => {
const logFileOperations: Promise<VideoLibraryLogFile>[] = []
// Fetch processed videos and logs
await videoStore.videoStoringDB.iterate((value, key) => {
const keys = await videoStore.videoStorage.keys()
for (const key of keys) {
if (videoStore.isVideoFilename(key)) {
videoFilesOperations.push(
(async () => {
const videoBlob = await videoStore.videoStoringDB.getItem<Blob>(key)
const videoBlob = await videoStore.videoStorage.getItem(key)
let url = ''
let isProcessed = true
if (videoBlob instanceof Blob) {
Expand All @@ -910,7 +911,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(key)
let url = ''
if (videoBlob instanceof Blob) {
url = URL.createObjectURL(videoBlob)
Expand All @@ -923,7 +924,7 @@ const fetchVideosAndLogData = async (): Promise<void> => {
})()
)
}
})
}
// Fetch unprocessed videos
const unprocessedVideos = await videoStore.unprocessedVideos
Expand Down
3 changes: 2 additions & 1 deletion src/components/mini-widgets/MiniVideoRecorder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ watch(nameSelectedStream, (newName) => {
// 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 keys = await videoStore.videoStorage.keys()
const nProcessedVideos = keys.filter((k) => videoStore.isVideoFilename(k)).length
const nFailedUnprocessedVideos = Object.keys(videoStore.keysFailedUnprocessedVideos).length
numberOfVideosOnDB.value = nProcessedVideos + nFailedUnprocessedVideos
}
Expand Down
127 changes: 127 additions & 0 deletions src/libs/videoStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import localforage from 'localforage'

import type { ElectronStorageDB, StorageDB } from '@/types/general'

import { isElectron } from './utils'

const throwIfNotElectron = (): void => {
if (!isElectron()) {
console.warn('Filesystem storage is only available in Electron.')
return
}
if (!window.electronAPI) {
console.error('electronAPI is not available on window object')
console.debug('Available window properties:', Object.keys(window))
throw new Error('Electron filesystem API is not properly initialized. This is likely a setup issue.')
}
}

/**
* Electron storage implementation.
* Uses the exposed IPC renderer API to store and retrieve data in the filesystem.
*/
class ElectronStorage implements ElectronStorageDB {
subFolders: string[]
electronAPI: ElectronStorageDB

/**
* Creates a new instance of the ElectronStorage class.
* @param {string[]} subFolders - The subfolders to store the data in.
*/
constructor(subFolders: string[]) {
throwIfNotElectron()

this.subFolders = subFolders
this.electronAPI = window.electronAPI as StorageDB
}

setItem = async (key: string, value: Blob): Promise<void> => {
throwIfNotElectron()
await this.electronAPI.setItem(key, value, this.subFolders)
}

getItem = async (key: string): Promise<Blob | null | undefined> => {
throwIfNotElectron()
return await this.electronAPI.getItem(key, this.subFolders)
}

removeItem = async (key: string): Promise<void> => {
throwIfNotElectron()
await this.electronAPI.removeItem(key, this.subFolders)
}

clear = async (): Promise<void> => {
throwIfNotElectron()
await this.electronAPI.clear(this.subFolders)
}

keys = async (): Promise<string[]> => {
throwIfNotElectron()
return await this.electronAPI.keys(this.subFolders)
}
}

/**
* LocalForage storage implementation.
* Uses the localforage library to store and retrieve data in the IndexedDB.
*/
class LocalForageStorage implements StorageDB {
localForage: LocalForage

/**
* Creates a new instance of the LocalForageStorage class.
* @param {string} name - The name of the localforage instance.
* @param {string} storeName - The name of the store to store the data in.
* @param {number} version - The version of the localforage instance.
* @param {string} description - The description of the localforage instance.
*/
constructor(name: string, storeName: string, version: number, description: string) {
this.localForage = localforage.createInstance({
driver: localforage.INDEXEDDB,
name: name,
storeName: storeName,
version: version,
description: description,
})
}

setItem = async (key: string, value: Blob): Promise<void> => {
await this.localForage.setItem(key, value)
}

getItem = async (key: string): Promise<Blob | null | undefined> => {
return await this.localForage.getItem(key)
}

removeItem = async (key: string): Promise<void> => {
await this.localForage.removeItem(key)
}

clear = async (): Promise<void> => {
await this.localForage.clear()
}

keys = async (): Promise<string[]> => {
return await this.localForage.keys()
}
}

const tempVideoChunksIndexdedDB: StorageDB = new LocalForageStorage(
'Cockpit - Temporary Video',
'cockpit-temp-video-db',
1.0,
'Database for storing the chunks of an ongoing recording, to be merged afterwards.'
)

const videoStoringIndexedDB: StorageDB = new LocalForageStorage(
'Cockpit - Video Recovery',
'cockpit-video-recovery-db',
1.0,
'Cockpit video recordings and their corresponding telemetry subtitles.'
)

const electronVideoStorage = new ElectronStorage(['videos'])
const temporaryElectronVideoStorage = new ElectronStorage(['videos', 'temporary-video-chunks'])

export const videoStorage = isElectron() ? electronVideoStorage : videoStoringIndexedDB
export const tempVideoStorage = isElectron() ? temporaryElectronVideoStorage : tempVideoChunksIndexdedDB
85 changes: 39 additions & 46 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 @@ -18,6 +17,7 @@ import eventTracker from '@/libs/external-telemetry/event-tracking'
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 @@ -281,7 +281,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 @@ -370,7 +370,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 @@ -387,7 +387,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 @@ -436,13 +436,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 @@ -462,7 +462,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 @@ -496,58 +496,44 @@ 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) => {
totalSizeBytes += (chunk as Blob).size
})
const keys = await tempVideoStorage.keys()
for (const key of keys) {
const blob = await tempVideoStorage.getItem(key)
if (blob) {
totalSizeBytes += 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 @@ -599,11 +585,14 @@ export const useVideoStore = defineStore('video', () => {
const dateFinish = new Date(info.dateFinish!)

debouncedUpdateFileProgress(info.fileName, 30, 'Grouping video chunks.')
await tempVideoChunksDB.iterate((videoChunk, chunkName) => {
if (chunkName.includes(hash)) {
chunks.push({ blob: videoChunk as Blob, name: chunkName })
const keys = await tempVideoStorage.keys()
const filteredKeys = keys.filter((key) => key.includes(hash))
for (const key of filteredKeys) {
const blob = await tempVideoStorage.getItem(key)
if (blob && blob.size > 0) {
chunks.push({ blob, name: key })
}
})
}

// As we advance through the processing, we update the last processing update date, so consumers know this is ongoing
updateLastProcessingUpdate(hash)
Expand Down Expand Up @@ -639,7 +628,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 @@ -653,7 +642,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 @@ -666,7 +655,11 @@ 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)
const keys = await tempVideoStorage.keys()
const filteredKeys = keys.filter((key) => key.includes(recordingHash) && key.includes('_'))
for (const key of filteredKeys) {
await tempVideoStorage.removeItem(key)
}
delete unprocessedVideos.value[recordingHash]
}

Expand Down Expand Up @@ -705,7 +698,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 @@ -732,14 +725,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 @@ -922,8 +915,8 @@ export const useVideoStore = defineStore('video', () => {
jitterBufferTarget,
zipMultipleFiles,
namesAvailableStreams,
videoStoringDB,
tempVideoChunksDB,
videoStorage,
tempVideoStorage,
streamsCorrespondency,
namessAvailableAbstractedStreams,
externalStreamId,
Expand Down

0 comments on commit fa42f76

Please sign in to comment.