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:
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<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-2XEhnc0l.js"></script>
|
<script type="module" crossorigin src="/assets/index-CHMLXxlv.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BTgh_FTp.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Deon0U_J.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+2
-10
@@ -10,7 +10,7 @@ import ConfigPanel from './components/ConfigPanel.vue'
|
|||||||
// ---- Stream (native <img> — no CORS issues) ----
|
// ---- Stream (native <img> — no CORS issues) ----
|
||||||
const { streamUrl, isLive, error, start, stop } = useMjpegStream()
|
const { streamUrl, isLive, error, start, stop } = useMjpegStream()
|
||||||
|
|
||||||
// ---- Recorder (canvas-based frame capture) ----
|
// ---- Recorder ----
|
||||||
const {
|
const {
|
||||||
recorderState,
|
recorderState,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
@@ -21,9 +21,6 @@ const {
|
|||||||
formatElapsed,
|
formatElapsed,
|
||||||
} = useRecorder()
|
} = useRecorder()
|
||||||
|
|
||||||
// ---- Playback panel ref ----
|
|
||||||
const playbackRef = ref<InstanceType<typeof PlaybackPanel> | null>(null)
|
|
||||||
|
|
||||||
// ---- Active tab ----
|
// ---- Active tab ----
|
||||||
type Tab = 'live' | 'playback'
|
type Tab = 'live' | 'playback'
|
||||||
const activeTab = ref<Tab>('live')
|
const activeTab = ref<Tab>('live')
|
||||||
@@ -39,12 +36,7 @@ function handleStartRecording() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleStopRecording() {
|
function handleStopRecording() {
|
||||||
stopRecording().then((recording) => {
|
stopRecording()
|
||||||
playbackRef.value?.loadRecordings()
|
|
||||||
if (recording) {
|
|
||||||
activeTab.value = 'playback'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsedFormatted = computed(() => formatElapsed(elapsedMs.value))
|
const elapsedFormatted = computed(() => formatElapsed(elapsedMs.value))
|
||||||
|
|||||||
@@ -1,50 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onUnmounted } from 'vue'
|
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 canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const playbackProgress = ref(0)
|
const playbackProgress = ref(0)
|
||||||
const playbackDuration = ref('00:00.00')
|
const playbackDuration = ref('00:00.00')
|
||||||
const playbackState = ref<'idle' | 'playing' | 'paused'>('idle')
|
const playbackState = ref<'idle' | 'playing' | 'paused'>('idle')
|
||||||
|
|
||||||
// Cache unpacked frames per recording
|
const currentFile = ref<RecordingFile | null>(null)
|
||||||
const framesCache = new Map<string, Frame[]>()
|
|
||||||
|
|
||||||
|
let activeFrames: Frame[] = []
|
||||||
|
let activeDuration = 0
|
||||||
let currentFrameIndex = 0
|
let currentFrameIndex = 0
|
||||||
let playbackStartTime = 0
|
let playbackStartTime = 0
|
||||||
let totalPausedTime = 0
|
let totalPausedTime = 0
|
||||||
let pausedAt = 0
|
let pausedAt = 0
|
||||||
let rafId = 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 {
|
function formatDuration(ms: number): string {
|
||||||
const totalSec = Math.floor(ms / 1000)
|
const totalSec = Math.floor(ms / 1000)
|
||||||
const min = Math.floor(totalSec / 60)
|
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')}`
|
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 {
|
function formatElapsed(ms: number): string {
|
||||||
const totalSec = Math.floor(Math.max(0, ms) / 1000)
|
const totalSec = Math.floor(Math.max(0, ms) / 1000)
|
||||||
const min = Math.floor(totalSec / 60)
|
const min = Math.floor(totalSec / 60)
|
||||||
@@ -119,19 +85,13 @@ function renderPlaybackFrame(
|
|||||||
img.src = url
|
img.src = url
|
||||||
}
|
}
|
||||||
|
|
||||||
let activeFrames: Frame[] = []
|
async function startPlayback(frames: Frame[], duration: number) {
|
||||||
let activeDuration = 0
|
|
||||||
|
|
||||||
async function startPlayback(recording: Recording) {
|
|
||||||
stopPlayback()
|
stopPlayback()
|
||||||
const c = getCanvasCtx()
|
const c = getCanvasCtx()
|
||||||
if (!c) return
|
if (!c) return
|
||||||
|
|
||||||
const frames = await getFrames(recording)
|
|
||||||
activeFrames = frames
|
activeFrames = frames
|
||||||
activeDuration = recording.duration
|
activeDuration = duration
|
||||||
|
|
||||||
playingId.value = recording.id
|
|
||||||
playbackState.value = 'playing'
|
playbackState.value = 'playing'
|
||||||
currentFrameIndex = 0
|
currentFrameIndex = 0
|
||||||
totalPausedTime = 0
|
totalPausedTime = 0
|
||||||
@@ -151,19 +111,17 @@ function pausePlayback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resumePlayback() {
|
function resumePlayback() {
|
||||||
if (playbackState.value !== 'paused' || !playingId.value) return
|
if (playbackState.value !== 'paused') return
|
||||||
const c = getCanvasCtx()
|
const c = getCanvasCtx()
|
||||||
if (!c) return
|
if (!c) return
|
||||||
|
|
||||||
totalPausedTime += performance.now() - pausedAt
|
totalPausedTime += performance.now() - pausedAt
|
||||||
playbackState.value = 'playing'
|
playbackState.value = 'playing'
|
||||||
|
|
||||||
renderPlaybackFrame(activeFrames, activeDuration, currentFrameIndex, c.canvas, c.ctx)
|
renderPlaybackFrame(activeFrames, activeDuration, currentFrameIndex, c.canvas, c.ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPlayback() {
|
function stopPlayback() {
|
||||||
playbackState.value = 'idle'
|
playbackState.value = 'idle'
|
||||||
playingId.value = null
|
|
||||||
playbackProgress.value = 0
|
playbackProgress.value = 0
|
||||||
playbackDuration.value = '00:00.00'
|
playbackDuration.value = '00:00.00'
|
||||||
currentFrameIndex = 0
|
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(() => {
|
onUnmounted(() => {
|
||||||
stopPlayback()
|
stopPlayback()
|
||||||
})
|
})
|
||||||
|
|
||||||
loadRecordings()
|
|
||||||
|
|
||||||
defineExpose({ loadRecordings })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="playback-panel">
|
<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 -->
|
<!-- Player canvas -->
|
||||||
<div v-if="playingId" class="player-container">
|
<div v-if="playbackState !== 'idle'" class="player-container">
|
||||||
<canvas ref="canvasRef" class="playback-canvas"></canvas>
|
<canvas ref="canvasRef" class="playback-canvas"></canvas>
|
||||||
|
|
||||||
<div class="playback-controls">
|
<div class="playback-controls">
|
||||||
@@ -222,44 +219,6 @@ defineExpose({ loadRecordings })
|
|||||||
<div class="progress-fill" :style="{ width: playbackProgress + '%' }"></div>
|
<div class="progress-fill" :style="{ width: playbackProgress + '%' }"></div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -275,6 +234,29 @@ defineExpose({ loadRecordings })
|
|||||||
color: #ddd;
|
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 */
|
||||||
.player-container {
|
.player-container {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -338,6 +320,15 @@ defineExpose({ loadRecordings })
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-load {
|
||||||
|
background: #3498db;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-load:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-play {
|
.btn-play {
|
||||||
background: #27ae60;
|
background: #27ae60;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -364,75 +355,4 @@ defineExpose({ loadRecordings })
|
|||||||
.btn-stop-play:hover {
|
.btn-stop-play:hover {
|
||||||
background: #444;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -35,16 +35,10 @@ const emit = defineEmits<{
|
|||||||
v-if="state === 'recording'"
|
v-if="state === 'recording'"
|
||||||
class="btn btn-stop"
|
class="btn btn-stop"
|
||||||
@click="emit('stop')"
|
@click="emit('stop')"
|
||||||
title="Stop and save recording"
|
title="Stop and download recording"
|
||||||
>
|
>
|
||||||
<span class="btn-icon">⏹</span>
|
<span class="btn-icon">⏹</span>
|
||||||
Stop & Save
|
Stop & Download
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Saving -->
|
|
||||||
<button v-if="state === 'saving'" class="btn btn-saving" disabled>
|
|
||||||
<span class="spinner"></span>
|
|
||||||
Saving…
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,25 +113,6 @@ const emit = defineEmits<{
|
|||||||
background: #c0392b;
|
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 {
|
.recording-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ref, type Ref } from 'vue'
|
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 SOI = new Uint8Array([0xff, 0xd8])
|
||||||
const EOI = new Uint8Array([0xff, 0xd9])
|
const EOI = new Uint8Array([0xff, 0xd9])
|
||||||
@@ -113,18 +113,15 @@ export function useRecorder() {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.name === 'AbortError') return
|
if (e.name === 'AbortError') return
|
||||||
recorderError.value = e.message || String(e)
|
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 !== 'recording') {
|
||||||
if (recorderState.value === 'saving') return null
|
|
||||||
reset()
|
reset()
|
||||||
return null
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
recorderState.value = 'saving'
|
|
||||||
running = false
|
running = false
|
||||||
|
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
@@ -140,41 +137,20 @@ export function useRecorder() {
|
|||||||
const savedFrames = [...frames]
|
const savedFrames = [...frames]
|
||||||
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) {
|
if (savedFrames.length === 0) {
|
||||||
clearTimeout(safetyTimer)
|
|
||||||
recorderError.value = 'No frames captured — check that the stream is accessible'
|
recorderError.value = 'No frames captured — check that the stream is accessible'
|
||||||
recorderState.value = 'idle'
|
recorderState.value = 'idle'
|
||||||
return null
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const recording: Recording = {
|
// Pack and download
|
||||||
id: crypto.randomUUID(),
|
const blob = packFrames(savedFrames)
|
||||||
name: `Recording ${new Date().toLocaleString()}`,
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||||
createdAt: new Date(),
|
downloadBlob(blob, `recording-${ts}.mjpeg`)
|
||||||
duration: elapsedMs.value,
|
|
||||||
frameCount: savedFrames.length,
|
|
||||||
data: packFrames(savedFrames),
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await saveRecording(recording)
|
|
||||||
recorderError.value = null
|
recorderError.value = null
|
||||||
return recording
|
|
||||||
} catch (e: any) {
|
|
||||||
recorderError.value = `Save failed: ${e.message || e}`
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
clearTimeout(safetyTimer)
|
|
||||||
recorderState.value = 'idle'
|
recorderState.value = 'idle'
|
||||||
}
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelRecording() {
|
function cancelRecording() {
|
||||||
|
|||||||
+14
-117
@@ -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,
|
* Binary format:
|
||||||
* 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`:
|
|
||||||
* [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(ms)] [4B BE uint32 jpegLen] [JPEG bytes]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface Frame {
|
export interface Frame {
|
||||||
@@ -19,56 +11,13 @@ export interface Frame {
|
|||||||
blob: Blob
|
blob: Blob
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordingMeta {
|
export interface RecordingFile {
|
||||||
id: string
|
|
||||||
name: string
|
name: string
|
||||||
createdAt: string // ISO
|
|
||||||
duration: number
|
duration: number
|
||||||
frameCount: 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
|
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 {
|
export function packFrames(frames: { timestamp: number; blob: Blob }[]): Blob {
|
||||||
const headerSize = 4 + frames.length * 8
|
const headerSize = 4 + frames.length * 8
|
||||||
const header = new ArrayBuffer(headerSize)
|
const header = new ArrayBuffer(headerSize)
|
||||||
@@ -106,66 +55,14 @@ export async function unpackFrames(data: Blob): Promise<Frame[]> {
|
|||||||
return frames
|
return frames
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- CRUD ----
|
/** Trigger browser download of a blob as a file */
|
||||||
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
export async function saveRecording(recording: Recording): Promise<void> {
|
const url = URL.createObjectURL(blob)
|
||||||
const dir = await getDir()
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
// Write frame data blob
|
a.download = filename
|
||||||
const dataFh = await dir.getFileHandle(`${recording.id}.frames`, { create: true })
|
document.body.appendChild(a)
|
||||||
const writable = await dataFh.createWritable()
|
a.click()
|
||||||
await writable.write(recording.data)
|
document.body.removeChild(a)
|
||||||
await writable.close()
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
// 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))
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user