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:
2026-08-01 18:55:06 +08:00
parent 97e294f1d6
commit 36413bbf60
3 changed files with 95 additions and 82 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>
+91 -78
View File
@@ -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)
request.onupgradeneeded = () => { const file = await fh.getFile()
const d = request.result const text = await file.text()
if (d.objectStoreNames.contains(STORE_NAME)) { return JSON.parse(text)
d.deleteObjectStore(STORE_NAME) } catch {
} // index doesn't exist yet
d.createObjectStore(STORE_NAME, { keyPath: 'id' }) return []
} }
}
request.onsuccess = () => { async function writeIndex(dir: FileSystemDirectoryHandle, index: RecordingMeta[]): Promise<void> {
db = request.result const fh = await dir.getFileHandle(INDEX_FILE, { create: true })
// If connection is lost (browser closes it), reset const writable = await fh.createWritable()
db.onclose = () => { await writable.write(JSON.stringify(index))
db = null await writable.close()
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)
await writable.close()
// Update index
const index = await readIndex(dir)
index.unshift({
id: recording.id,
name: recording.name,
createdAt: recording.createdAt.toISOString(), createdAt: recording.createdAt.toISOString(),
duration: recording.duration,
frameCount: recording.frameCount,
dataSize: recording.data.size,
}) })
tx.oncomplete = () => resolve() await writeIndex(dir, index)
tx.onerror = () => reject(tx.error)
tx.onabort = () => reject(new Error('Transaction aborted'))
})
} }
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),
request.onerror = () => reject(request.error) duration: meta.duration,
frameCount: meta.frameCount,
data: file,
}) })
} catch {
// Orphaned index entry — skip
}
}
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))
} }