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">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
<body>
+89 -76
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
* multi-blob transaction issues.
* Uses Origin Private File System (OPFS) for reliable large-blob storage,
* 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]
* Per frame: [4B BE uint32 timestamp] [4B BE uint32 jpegLen] [JPEG bytes]
*/
@@ -14,62 +19,52 @@ export interface Frame {
blob: Blob
}
export interface RecordingMeta {
id: string
name: string
createdAt: string // ISO
duration: number
frameCount: number
dataSize: number
}
export interface Recording {
id: string
name: string
createdAt: Date
duration: number
frameCount: number
/** Blob reference — lazy-loaded from OPFS */
data: Blob
}
const DB_NAME = 'mjpeg-viewer'
const DB_VERSION = 2
const STORE_NAME = 'recordings'
const DIR = 'recordings'
const INDEX_FILE = 'index.json'
// ---- Singleton connection ----
let db: IDBDatabase | null = null
let dbPromise: Promise<IDBDatabase> | null = null
// ---- OPFS helpers ----
function getDB(): Promise<IDBDatabase> {
if (db) return Promise.resolve(db)
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const d = request.result
if (d.objectStoreNames.contains(STORE_NAME)) {
d.deleteObjectStore(STORE_NAME)
}
d.createObjectStore(STORE_NAME, { keyPath: 'id' })
async function getDir(): Promise<FileSystemDirectoryHandle> {
const root = await navigator.storage.getDirectory()
return root.getDirectoryHandle(DIR, { create: true })
}
request.onsuccess = () => {
db = request.result
// If connection is lost (browser closes it), reset
db.onclose = () => {
db = null
dbPromise = null
async function readIndex(dir: FileSystemDirectoryHandle): Promise<RecordingMeta[]> {
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 []
}
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
async function writeIndex(dir: FileSystemDirectoryHandle, index: RecordingMeta[]): Promise<void> {
const fh = await dir.getFileHandle(INDEX_FILE, { create: true })
const writable = await fh.createWritable()
await writable.write(JSON.stringify(index))
await writable.close()
}
// ---- Binary pack / unpack ----
@@ -114,45 +109,63 @@ export async function unpackFrames(data: Blob): Promise<Frame[]> {
// ---- CRUD ----
export async function saveRecording(recording: Recording): Promise<void> {
const d = await getDB()
return new Promise((resolve, reject) => {
const tx = d.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
store.put({
...recording,
const dir = await getDir()
// Write frame data blob
const dataFh = await dir.getFileHandle(`${recording.id}.frames`, { create: true })
const writable = await dataFh.createWritable()
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(),
duration: recording.duration,
frameCount: recording.frameCount,
dataSize: recording.data.size,
})
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
tx.onabort = () => reject(new Error('Transaction aborted'))
})
await writeIndex(dir, index)
}
export async function getAllRecordings(): Promise<Recording[]> {
const d = await getDB()
return new Promise((resolve, reject) => {
const tx = d.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.getAll()
request.onsuccess = () => {
const results = (request.result || []).map((r: any) => ({
...r,
createdAt: new Date(r.createdAt),
}))
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
resolve(results)
}
request.onerror = () => reject(request.error)
const dir = await getDir()
const index = await readIndex(dir)
// Load data blob for each recording
const results: Recording[] = []
for (const meta of index) {
try {
const fh = await dir.getFileHandle(`${meta.id}.frames`)
const file = await fh.getFile()
results.push({
id: meta.id,
name: meta.name,
createdAt: new Date(meta.createdAt),
duration: meta.duration,
frameCount: meta.frameCount,
data: file,
})
} catch {
// Orphaned index entry — skip
}
}
return results
}
export async function deleteRecording(id: string): Promise<void> {
const d = await getDB()
return new Promise((resolve, reject) => {
const tx = d.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
store.delete(id)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
const dir = await getDir()
// Delete frame data
try {
await dir.removeEntry(`${id}.frames`)
} catch {
// file already gone
}
// Update index
const index = await readIndex(dir)
await writeIndex(dir, index.filter((m) => m.id !== id))
}