fix: replace IndexedDB with Origin Private File System (OPFS)
OPFS (navigator.storage.getDirectory) provides direct filesystem
access scoped to the app's origin. Much more reliable than IndexedDB
for large blob storage — no transaction blocking, no connection issues.
Layout: recordings/{id}.frames (packed binary) + index.json (metadata)
Each recording stored as a single file write then close.
This commit is contained in:
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MJPEG Stream Viewer</title>
|
<title>MJPEG Stream Viewer</title>
|
||||||
<script type="module" crossorigin src="/assets/index-B6qcBY8r.js"></script>
|
<script type="module" crossorigin src="/assets/index-2XEhnc0l.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BTgh_FTp.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BTgh_FTp.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* IndexedDB wrapper — singleton connection, never closed during app lifetime.
|
* Storage layer for MJPEG recordings.
|
||||||
*
|
*
|
||||||
* All frames packed into a single Blob per recording to avoid
|
* Uses Origin Private File System (OPFS) for reliable large-blob storage,
|
||||||
* multi-blob transaction issues.
|
* with metadata stored alongside as a JSON index.
|
||||||
*
|
*
|
||||||
* Binary format inside `data`:
|
* Directory layout under OPFS root:
|
||||||
|
* recordings/
|
||||||
|
* index.json — array of RecordingMeta
|
||||||
|
* {id}.frames — packed binary blob (all frames)
|
||||||
|
*
|
||||||
|
* Binary format inside `{id}.frames`:
|
||||||
* [4B BE uint32 frameCount]
|
* [4B BE uint32 frameCount]
|
||||||
* Per frame: [4B BE uint32 timestamp] [4B BE uint32 jpegLen] [JPEG bytes]
|
* Per frame: [4B BE uint32 timestamp] [4B BE uint32 jpegLen] [JPEG bytes]
|
||||||
*/
|
*/
|
||||||
@@ -14,62 +19,52 @@ export interface Frame {
|
|||||||
blob: Blob
|
blob: Blob
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecordingMeta {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
createdAt: string // ISO
|
||||||
|
duration: number
|
||||||
|
frameCount: number
|
||||||
|
dataSize: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface Recording {
|
export interface Recording {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
duration: number
|
duration: number
|
||||||
frameCount: number
|
frameCount: number
|
||||||
|
/** Blob reference — lazy-loaded from OPFS */
|
||||||
data: Blob
|
data: Blob
|
||||||
}
|
}
|
||||||
|
|
||||||
const DB_NAME = 'mjpeg-viewer'
|
const DIR = 'recordings'
|
||||||
const DB_VERSION = 2
|
const INDEX_FILE = 'index.json'
|
||||||
const STORE_NAME = 'recordings'
|
|
||||||
|
|
||||||
// ---- Singleton connection ----
|
// ---- OPFS helpers ----
|
||||||
let db: IDBDatabase | null = null
|
|
||||||
let dbPromise: Promise<IDBDatabase> | null = null
|
|
||||||
|
|
||||||
function getDB(): Promise<IDBDatabase> {
|
async function getDir(): Promise<FileSystemDirectoryHandle> {
|
||||||
if (db) return Promise.resolve(db)
|
const root = await navigator.storage.getDirectory()
|
||||||
if (dbPromise) return dbPromise
|
return root.getDirectoryHandle(DIR, { create: true })
|
||||||
|
}
|
||||||
|
|
||||||
dbPromise = new Promise((resolve, reject) => {
|
async function readIndex(dir: FileSystemDirectoryHandle): Promise<RecordingMeta[]> {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
try {
|
||||||
|
const fh = await dir.getFileHandle(INDEX_FILE)
|
||||||
|
const file = await fh.getFile()
|
||||||
|
const text = await file.text()
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
// index doesn't exist yet
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
request.onupgradeneeded = () => {
|
async function writeIndex(dir: FileSystemDirectoryHandle, index: RecordingMeta[]): Promise<void> {
|
||||||
const d = request.result
|
const fh = await dir.getFileHandle(INDEX_FILE, { create: true })
|
||||||
if (d.objectStoreNames.contains(STORE_NAME)) {
|
const writable = await fh.createWritable()
|
||||||
d.deleteObjectStore(STORE_NAME)
|
await writable.write(JSON.stringify(index))
|
||||||
}
|
await writable.close()
|
||||||
d.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
db = request.result
|
|
||||||
// If connection is lost (browser closes it), reset
|
|
||||||
db.onclose = () => {
|
|
||||||
db = null
|
|
||||||
dbPromise = null
|
|
||||||
}
|
|
||||||
resolve(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onerror = () => {
|
|
||||||
dbPromise = null
|
|
||||||
reject(request.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onblocked = () => {
|
|
||||||
// A previous connection is still open — this shouldn't happen
|
|
||||||
// with singleton pattern, but if it does, reject to trigger retry
|
|
||||||
dbPromise = null
|
|
||||||
reject(new Error('Database blocked by another connection'))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return dbPromise
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Binary pack / unpack ----
|
// ---- Binary pack / unpack ----
|
||||||
@@ -114,45 +109,63 @@ export async function unpackFrames(data: Blob): Promise<Frame[]> {
|
|||||||
// ---- CRUD ----
|
// ---- CRUD ----
|
||||||
|
|
||||||
export async function saveRecording(recording: Recording): Promise<void> {
|
export async function saveRecording(recording: Recording): Promise<void> {
|
||||||
const d = await getDB()
|
const dir = await getDir()
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const tx = d.transaction(STORE_NAME, 'readwrite')
|
// Write frame data blob
|
||||||
const store = tx.objectStore(STORE_NAME)
|
const dataFh = await dir.getFileHandle(`${recording.id}.frames`, { create: true })
|
||||||
store.put({
|
const writable = await dataFh.createWritable()
|
||||||
...recording,
|
await writable.write(recording.data)
|
||||||
createdAt: recording.createdAt.toISOString(),
|
await writable.close()
|
||||||
})
|
|
||||||
tx.oncomplete = () => resolve()
|
// Update index
|
||||||
tx.onerror = () => reject(tx.error)
|
const index = await readIndex(dir)
|
||||||
tx.onabort = () => reject(new Error('Transaction aborted'))
|
index.unshift({
|
||||||
|
id: recording.id,
|
||||||
|
name: recording.name,
|
||||||
|
createdAt: recording.createdAt.toISOString(),
|
||||||
|
duration: recording.duration,
|
||||||
|
frameCount: recording.frameCount,
|
||||||
|
dataSize: recording.data.size,
|
||||||
})
|
})
|
||||||
|
await writeIndex(dir, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllRecordings(): Promise<Recording[]> {
|
export async function getAllRecordings(): Promise<Recording[]> {
|
||||||
const d = await getDB()
|
const dir = await getDir()
|
||||||
return new Promise((resolve, reject) => {
|
const index = await readIndex(dir)
|
||||||
const tx = d.transaction(STORE_NAME, 'readonly')
|
|
||||||
const store = tx.objectStore(STORE_NAME)
|
// Load data blob for each recording
|
||||||
const request = store.getAll()
|
const results: Recording[] = []
|
||||||
request.onsuccess = () => {
|
for (const meta of index) {
|
||||||
const results = (request.result || []).map((r: any) => ({
|
try {
|
||||||
...r,
|
const fh = await dir.getFileHandle(`${meta.id}.frames`)
|
||||||
createdAt: new Date(r.createdAt),
|
const file = await fh.getFile()
|
||||||
}))
|
results.push({
|
||||||
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
id: meta.id,
|
||||||
resolve(results)
|
name: meta.name,
|
||||||
|
createdAt: new Date(meta.createdAt),
|
||||||
|
duration: meta.duration,
|
||||||
|
frameCount: meta.frameCount,
|
||||||
|
data: file,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Orphaned index entry — skip
|
||||||
}
|
}
|
||||||
request.onerror = () => reject(request.error)
|
}
|
||||||
})
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRecording(id: string): Promise<void> {
|
export async function deleteRecording(id: string): Promise<void> {
|
||||||
const d = await getDB()
|
const dir = await getDir()
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const tx = d.transaction(STORE_NAME, 'readwrite')
|
// Delete frame data
|
||||||
const store = tx.objectStore(STORE_NAME)
|
try {
|
||||||
store.delete(id)
|
await dir.removeEntry(`${id}.frames`)
|
||||||
tx.oncomplete = () => resolve()
|
} catch {
|
||||||
tx.onerror = () => reject(tx.error)
|
// file already gone
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Update index
|
||||||
|
const index = await readIndex(dir)
|
||||||
|
await writeIndex(dir, index.filter((m) => m.id !== id))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user