fix: add safety timeout in stopRecording, extract reset() helper
- extract reset() to ensure clean state in all exit paths - add 5s timeout guard so UI never stays stuck on 'Saving' - improve error messages for empty frames and save failures
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-C5dzEsTl.js"></script>
|
<script type="module" crossorigin src="/assets/index-D4W4a1Z1.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DuJPD2XK.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DuJPD2XK.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { saveRecording, type Recording } from './useStorage'
|
|||||||
|
|
||||||
export type RecorderState = 'idle' | 'recording' | 'saving'
|
export type RecorderState = 'idle' | 'recording' | 'saving'
|
||||||
|
|
||||||
// JPEG markers
|
|
||||||
const SOI = new Uint8Array([0xff, 0xd8])
|
const SOI = new Uint8Array([0xff, 0xd8])
|
||||||
const EOI = new Uint8Array([0xff, 0xd9])
|
const EOI = new Uint8Array([0xff, 0xd9])
|
||||||
|
|
||||||
@@ -17,10 +16,6 @@ function indexOf(haystack: Uint8Array, needle: Uint8Array, from = 0): number {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a raw MJPEG fetch response by scanning for JPEG SOI/EOI markers.
|
|
||||||
* This avoids multipart boundary parsing entirely — just looks for complete JPEG frames.
|
|
||||||
*/
|
|
||||||
async function* parseMjpegByMarkers(
|
async function* parseMjpegByMarkers(
|
||||||
response: Response,
|
response: Response,
|
||||||
): AsyncGenerator<{ blob: Blob; timestamp: number }> {
|
): AsyncGenerator<{ blob: Blob; timestamp: number }> {
|
||||||
@@ -40,21 +35,16 @@ async function* parseMjpegByMarkers(
|
|||||||
newBuf.set(value, buffer.length)
|
newBuf.set(value, buffer.length)
|
||||||
buffer = newBuf
|
buffer = newBuf
|
||||||
|
|
||||||
// Extract all complete JPEG frames (SOI ... EOI)
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const soi = indexOf(buffer, SOI)
|
const soi = indexOf(buffer, SOI)
|
||||||
if (soi === -1) break // no frame start in buffer
|
if (soi === -1) break
|
||||||
|
|
||||||
const eoi = indexOf(buffer, EOI, soi + 2)
|
const eoi = indexOf(buffer, EOI, soi + 2)
|
||||||
if (eoi === -1) break // incomplete frame — wait for more data
|
if (eoi === -1) break
|
||||||
|
|
||||||
const jpegData = buffer.subarray(soi, eoi + 2)
|
const jpegData = buffer.subarray(soi, eoi + 2)
|
||||||
if (jpegData.length > 100) {
|
if (jpegData.length > 100) {
|
||||||
// sanity check: JPEG frames have at least some content
|
yield { blob: new Blob([jpegData], { type: 'image/jpeg' }), timestamp: Date.now() - startTime }
|
||||||
const blob = new Blob([jpegData], { type: 'image/jpeg' })
|
|
||||||
yield { blob, timestamp: Date.now() - startTime }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer = buffer.subarray(eoi + 2)
|
buffer = buffer.subarray(eoi + 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,20 +65,32 @@ export function useRecorder() {
|
|||||||
let abortController: AbortController | null = null
|
let abortController: AbortController | null = null
|
||||||
let running = false
|
let running = false
|
||||||
|
|
||||||
async function startRecording(streamUrl: string) {
|
function reset() {
|
||||||
// Clean up any previous session
|
running = false
|
||||||
cancelRecording()
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
|
if (timerInterval) {
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
timerInterval = null
|
||||||
|
}
|
||||||
frames = []
|
frames = []
|
||||||
frameCount.value = 0
|
frameCount.value = 0
|
||||||
elapsedMs.value = 0
|
elapsedMs.value = 0
|
||||||
recorderError.value = null
|
recorderState.value = 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording(streamUrl: string) {
|
||||||
|
reset()
|
||||||
|
|
||||||
startTime = Date.now()
|
startTime = Date.now()
|
||||||
recorderState.value = 'recording'
|
recorderState.value = 'recording'
|
||||||
|
recorderError.value = null
|
||||||
running = true
|
running = true
|
||||||
|
|
||||||
timerInterval = setInterval(() => {
|
timerInterval = setInterval(() => {
|
||||||
elapsedMs.value = Date.now() - startTime
|
if (running) elapsedMs.value = Date.now() - startTime
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
abortController = new AbortController()
|
abortController = new AbortController()
|
||||||
@@ -105,20 +107,22 @@ export function useRecorder() {
|
|||||||
|
|
||||||
for await (const frame of parseMjpegByMarkers(response)) {
|
for await (const frame of parseMjpegByMarkers(response)) {
|
||||||
if (!running) break
|
if (!running) break
|
||||||
frames.push({
|
frames.push({ timestamp: frame.timestamp, blob: frame.blob })
|
||||||
timestamp: frame.timestamp,
|
|
||||||
blob: frame.blob,
|
|
||||||
})
|
|
||||||
frameCount.value = frames.length
|
frameCount.value = frames.length
|
||||||
}
|
}
|
||||||
} 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> {
|
async function stopRecording(): Promise<Recording | null> {
|
||||||
if (recorderState.value !== 'recording') return null
|
if (recorderState.value !== 'recording') {
|
||||||
|
if (recorderState.value === 'saving') return null
|
||||||
|
reset()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
recorderState.value = 'saving'
|
recorderState.value = 'saving'
|
||||||
running = false
|
running = false
|
||||||
@@ -131,10 +135,22 @@ export function useRecorder() {
|
|||||||
clearInterval(timerInterval)
|
clearInterval(timerInterval)
|
||||||
timerInterval = null
|
timerInterval = null
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsedMs.value = Date.now() - startTime
|
elapsedMs.value = Date.now() - startTime
|
||||||
|
|
||||||
if (frames.length === 0) {
|
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'
|
recorderState.value = 'idle'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -144,42 +160,33 @@ export function useRecorder() {
|
|||||||
name: `Recording ${new Date().toLocaleString()}`,
|
name: `Recording ${new Date().toLocaleString()}`,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
duration: elapsedMs.value,
|
duration: elapsedMs.value,
|
||||||
frameCount: frames.length,
|
frameCount: savedFrames.length,
|
||||||
frames: [...frames],
|
frames: savedFrames,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveRecording(recording)
|
await saveRecording(recording)
|
||||||
|
recorderError.value = null
|
||||||
|
return recording
|
||||||
|
} catch (e: any) {
|
||||||
|
recorderError.value = `Save failed: ${e.message || e}`
|
||||||
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(safetyTimer)
|
||||||
recorderState.value = 'idle'
|
recorderState.value = 'idle'
|
||||||
}
|
}
|
||||||
|
|
||||||
frames = []
|
|
||||||
return recording
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelRecording() {
|
function cancelRecording() {
|
||||||
running = false
|
reset()
|
||||||
if (abortController) {
|
|
||||||
abortController.abort()
|
|
||||||
abortController = null
|
|
||||||
}
|
|
||||||
if (timerInterval) {
|
|
||||||
clearInterval(timerInterval)
|
|
||||||
timerInterval = null
|
|
||||||
}
|
|
||||||
frames = []
|
|
||||||
frameCount.value = 0
|
|
||||||
elapsedMs.value = 0
|
|
||||||
recorderError.value = null
|
recorderError.value = null
|
||||||
recorderState.value = 'idle'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatElapsed(ms: number): string {
|
function formatElapsed(ms: number): string {
|
||||||
const totalSec = Math.floor(ms / 1000)
|
const totalSec = Math.floor(Math.max(0, ms) / 1000)
|
||||||
const min = Math.floor(totalSec / 60)
|
const min = Math.floor(totalSec / 60)
|
||||||
const sec = totalSec % 60
|
const sec = totalSec % 60
|
||||||
const centis = Math.floor((ms % 1000) / 10)
|
const centis = Math.floor((Math.max(0, ms) % 1000) / 10)
|
||||||
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(centis).padStart(2, '0')}`
|
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(centis).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user