refactor: stop recording triggers file download instead of browser storage

Recording flow: click Stop → frames packed into .mjpeg blob → browser
download triggered immediately. No more IndexedDB or OPFS.

Playback: use 'Load Recording File' button to open a .mjpeg file.
Removed all storage dependencies — pure file-based, 100% reliable.
This commit is contained in:
2026-08-01 18:59:32 +08:00
parent 36413bbf60
commit d2ecb1a4ed
10 changed files with 119 additions and 359 deletions
-1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<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-2XEhnc0l.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BTgh_FTp.css">
<script type="module" crossorigin src="/assets/index-CHMLXxlv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Deon0U_J.css">
</head>
<body>
<div id="app"></div>
+2 -10
View File
@@ -10,7 +10,7 @@ import ConfigPanel from './components/ConfigPanel.vue'
// ---- Stream (native <img> — no CORS issues) ----
const { streamUrl, isLive, error, start, stop } = useMjpegStream()
// ---- Recorder (canvas-based frame capture) ----
// ---- Recorder ----
const {
recorderState,
elapsedMs,
@@ -21,9 +21,6 @@ const {
formatElapsed,
} = useRecorder()
// ---- Playback panel ref ----
const playbackRef = ref<InstanceType<typeof PlaybackPanel> | null>(null)
// ---- Active tab ----
type Tab = 'live' | 'playback'
const activeTab = ref<Tab>('live')
@@ -39,12 +36,7 @@ function handleStartRecording() {
}
function handleStopRecording() {
stopRecording().then((recording) => {
playbackRef.value?.loadRecordings()
if (recording) {
activeTab.value = 'playback'
}
})
stopRecording()
}
const elapsedFormatted = computed(() => formatElapsed(elapsedMs.value))
+85 -165
View File
@@ -1,50 +1,23 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { type Recording, type Frame, getAllRecordings, deleteRecording, unpackFrames } from '../composables/useStorage'
import { unpackFrames, type Frame, type RecordingFile } from '../composables/useStorage'
const recordings = ref<Recording[]>([])
const loading = ref(false)
const playingId = ref<string | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const playbackProgress = ref(0)
const playbackDuration = ref('00:00.00')
const playbackState = ref<'idle' | 'playing' | 'paused'>('idle')
// Cache unpacked frames per recording
const framesCache = new Map<string, Frame[]>()
const currentFile = ref<RecordingFile | null>(null)
let activeFrames: Frame[] = []
let activeDuration = 0
let currentFrameIndex = 0
let playbackStartTime = 0
let totalPausedTime = 0
let pausedAt = 0
let rafId = 0
async function getFrames(rec: Recording): Promise<Frame[]> {
const cached = framesCache.get(rec.id)
if (cached) return cached
const frames = await unpackFrames(rec.data)
framesCache.set(rec.id, frames)
return frames
}
async function loadRecordings() {
loading.value = true
try {
recordings.value = await getAllRecordings()
} finally {
loading.value = false
}
}
async function handleDelete(id: string) {
if (playingId.value === id) {
stopPlayback()
}
framesCache.delete(id)
await deleteRecording(id)
recordings.value = recordings.value.filter((r) => r.id !== id)
}
function formatDuration(ms: number): string {
const totalSec = Math.floor(ms / 1000)
const min = Math.floor(totalSec / 60)
@@ -52,13 +25,6 @@ function formatDuration(ms: number): string {
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
}
function formatFileSize(recording: Recording): string {
const totalBytes = recording.data.size
if (totalBytes < 1024) return `${totalBytes} B`
if (totalBytes < 1024 * 1024) return `${(totalBytes / 1024).toFixed(1)} KB`
return `${(totalBytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatElapsed(ms: number): string {
const totalSec = Math.floor(Math.max(0, ms) / 1000)
const min = Math.floor(totalSec / 60)
@@ -119,19 +85,13 @@ function renderPlaybackFrame(
img.src = url
}
let activeFrames: Frame[] = []
let activeDuration = 0
async function startPlayback(recording: Recording) {
async function startPlayback(frames: Frame[], duration: number) {
stopPlayback()
const c = getCanvasCtx()
if (!c) return
const frames = await getFrames(recording)
activeFrames = frames
activeDuration = recording.duration
playingId.value = recording.id
activeDuration = duration
playbackState.value = 'playing'
currentFrameIndex = 0
totalPausedTime = 0
@@ -151,19 +111,17 @@ function pausePlayback() {
}
function resumePlayback() {
if (playbackState.value !== 'paused' || !playingId.value) return
if (playbackState.value !== 'paused') return
const c = getCanvasCtx()
if (!c) return
totalPausedTime += performance.now() - pausedAt
playbackState.value = 'playing'
renderPlaybackFrame(activeFrames, activeDuration, currentFrameIndex, c.canvas, c.ctx)
}
function stopPlayback() {
playbackState.value = 'idle'
playingId.value = null
playbackProgress.value = 0
playbackDuration.value = '00:00.00'
currentFrameIndex = 0
@@ -180,21 +138,60 @@ function stopPlayback() {
}
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
stopPlayback()
try {
const frames = await unpackFrames(file)
currentFile.value = {
name: file.name,
duration: frames.length > 0 ? frames[frames.length - 1]!.timestamp : 0,
frameCount: frames.length,
data: file,
}
await startPlayback(frames, currentFile.value.duration)
} catch (e: any) {
currentFile.value = null
alert(`Failed to load recording: ${e.message || e}`)
}
// Reset input so the same file can be re-selected
input.value = ''
}
onUnmounted(() => {
stopPlayback()
})
loadRecordings()
defineExpose({ loadRecordings })
</script>
<template>
<div class="playback-panel">
<h2 class="section-title">Saved Recordings</h2>
<h2 class="section-title">Playback</h2>
<!-- File loader -->
<div class="load-area">
<input
ref="fileInputRef"
type="file"
accept=".mjpeg"
class="file-input-hidden"
@change="handleFileSelect"
/>
<button class="btn btn-load" @click="fileInputRef?.click()">
📂 Load Recording File
</button>
<span v-if="currentFile" class="file-info">
{{ currentFile.name }} · {{ currentFile.frameCount }} frames · {{ formatDuration(currentFile.duration) }}
</span>
<span v-else class="file-hint">Select a .mjpeg file to play back</span>
</div>
<!-- Player canvas -->
<div v-if="playingId" class="player-container">
<div v-if="playbackState !== 'idle'" class="player-container">
<canvas ref="canvasRef" class="playback-canvas"></canvas>
<div class="playback-controls">
@@ -222,44 +219,6 @@ defineExpose({ loadRecordings })
<div class="progress-fill" :style="{ width: playbackProgress + '%' }"></div>
</div>
</div>
<!-- Recording list -->
<div v-if="loading" class="list-status">Loading…</div>
<div v-else-if="recordings.length === 0" class="list-empty">
No recordings yet. Start a stream and press Record.
</div>
<ul v-else class="recording-list">
<li
v-for="rec in recordings"
:key="rec.id"
class="recording-item"
:class="{ active: rec.id === playingId }"
>
<div class="rec-info">
<span class="rec-name">{{ rec.name }}</span>
<span class="rec-meta">
{{ rec.createdAt.toLocaleString() }} ·
{{ formatDuration(rec.duration) }} ·
{{ rec.frameCount }} frames ·
{{ formatFileSize(rec) }}
</span>
</div>
<div class="rec-actions">
<button
v-if="rec.id !== playingId"
class="btn btn-sm btn-play"
@click="startPlayback(rec)"
>
▶ Play
</button>
<button class="btn btn-sm btn-delete" @click="handleDelete(rec.id)">
🗑
</button>
</div>
</li>
</ul>
</div>
</template>
@@ -275,6 +234,29 @@ defineExpose({ loadRecordings })
color: #ddd;
}
/* Load area */
.load-area {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.file-input-hidden {
display: none;
}
.file-info {
font-size: 0.9rem;
color: #27ae60;
}
.file-hint {
font-size: 0.85rem;
color: #666;
}
/* Player */
.player-container {
margin-bottom: 20px;
@@ -338,6 +320,15 @@ defineExpose({ loadRecordings })
font-size: 0.82rem;
}
.btn-load {
background: #3498db;
color: #fff;
}
.btn-load:hover {
background: #2980b9;
}
.btn-play {
background: #27ae60;
color: #fff;
@@ -364,75 +355,4 @@ defineExpose({ loadRecordings })
.btn-stop-play:hover {
background: #444;
}
.btn-delete {
background: transparent;
color: #999;
font-size: 1rem;
padding: 4px 8px;
}
.btn-delete:hover {
color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
}
/* List */
.list-status,
.list-empty {
color: #777;
font-size: 0.95rem;
padding: 16px 0;
text-align: center;
}
.recording-list {
list-style: none;
padding: 0;
margin: 0;
}
.recording-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border: 1px solid #2a2a3e;
border-radius: 8px;
margin-bottom: 8px;
background: #141428;
gap: 12px;
}
.recording-item.active {
border-color: #3498db;
}
.rec-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.rec-name {
font-weight: 600;
font-size: 0.95rem;
color: #ddd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rec-meta {
font-size: 0.8rem;
color: #777;
}
.rec-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
</style>
+2 -27
View File
@@ -35,16 +35,10 @@ const emit = defineEmits<{
v-if="state === 'recording'"
class="btn btn-stop"
@click="emit('stop')"
title="Stop and save recording"
title="Stop and download recording"
>
<span class="btn-icon">⏹</span>
Stop &amp; Save
</button>
<!-- Saving -->
<button v-if="state === 'saving'" class="btn btn-saving" disabled>
<span class="spinner"></span>
Saving…
Stop &amp; Download
</button>
</div>
@@ -119,25 +113,6 @@ const emit = defineEmits<{
background: #c0392b;
}
.btn-saving {
background: #666;
color: #fff;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.recording-info {
display: flex;
align-items: center;
+10 -34
View File
@@ -1,7 +1,7 @@
import { ref, type Ref } from 'vue'
import { saveRecording, packFrames, type Recording } from './useStorage'
import { packFrames, downloadBlob } from './useStorage'
export type RecorderState = 'idle' | 'recording' | 'saving'
export type RecorderState = 'idle' | 'recording'
const SOI = new Uint8Array([0xff, 0xd8])
const EOI = new Uint8Array([0xff, 0xd9])
@@ -113,18 +113,15 @@ export function useRecorder() {
} catch (e: any) {
if (e.name === 'AbortError') return
recorderError.value = e.message || String(e)
// Don't reset state here — user may still want to stop and see the error
}
}
async function stopRecording(): Promise<Recording | null> {
function stopRecording(): boolean {
if (recorderState.value !== 'recording') {
if (recorderState.value === 'saving') return null
reset()
return null
return false
}
recorderState.value = 'saving'
running = false
if (abortController) {
@@ -140,41 +137,20 @@ export function useRecorder() {
const savedFrames = [...frames]
frames = []
// Safety: force back to idle after 5s even if IndexedDB hangs
const safetyTimer = setTimeout(() => {
if (recorderState.value === 'saving') {
recorderError.value = 'Save timed out'
recorderState.value = 'idle'
}
}, 5000)
if (savedFrames.length === 0) {
clearTimeout(safetyTimer)
recorderError.value = 'No frames captured — check that the stream is accessible'
recorderState.value = 'idle'
return null
return false
}
const recording: Recording = {
id: crypto.randomUUID(),
name: `Recording ${new Date().toLocaleString()}`,
createdAt: new Date(),
duration: elapsedMs.value,
frameCount: savedFrames.length,
data: packFrames(savedFrames),
}
// Pack and download
const blob = packFrames(savedFrames)
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
downloadBlob(blob, `recording-${ts}.mjpeg`)
try {
await saveRecording(recording)
recorderError.value = null
return recording
} catch (e: any) {
recorderError.value = `Save failed: ${e.message || e}`
return null
} finally {
clearTimeout(safetyTimer)
recorderState.value = 'idle'
}
return true
}
function cancelRecording() {
+14 -117
View File
@@ -1,17 +1,9 @@
/**
* Storage layer for MJPEG recordings.
* MJPEG frame packing / unpacking for download & playback.
*
* Uses Origin Private File System (OPFS) for reliable large-blob storage,
* with metadata stored alongside as a JSON index.
*
* Directory layout under OPFS root:
* recordings/
* index.json — array of RecordingMeta
* {id}.frames — packed binary blob (all frames)
*
* Binary format inside `{id}.frames`:
* Binary format:
* [4B BE uint32 frameCount]
* Per frame: [4B BE uint32 timestamp] [4B BE uint32 jpegLen] [JPEG bytes]
* Per frame: [4B BE uint32 timestamp(ms)] [4B BE uint32 jpegLen] [JPEG bytes]
*/
export interface Frame {
@@ -19,56 +11,13 @@ export interface Frame {
blob: Blob
}
export interface RecordingMeta {
id: string
export interface RecordingFile {
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 DIR = 'recordings'
const INDEX_FILE = 'index.json'
// ---- OPFS helpers ----
async function getDir(): Promise<FileSystemDirectoryHandle> {
const root = await navigator.storage.getDirectory()
return root.getDirectoryHandle(DIR, { create: true })
}
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 []
}
}
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 ----
export function packFrames(frames: { timestamp: number; blob: Blob }[]): Blob {
const headerSize = 4 + frames.length * 8
const header = new ArrayBuffer(headerSize)
@@ -106,66 +55,14 @@ export async function unpackFrames(data: Blob): Promise<Frame[]> {
return frames
}
// ---- CRUD ----
export async function saveRecording(recording: Recording): Promise<void> {
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,
})
await writeIndex(dir, index)
}
export async function getAllRecordings(): Promise<Recording[]> {
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 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))
/** Trigger browser download of a blob as a file */
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}