fix: use JPEG SOI/EOI marker scanning instead of multipart parsing
Recording now scans the fetch response for JPEG start-of-image (0xFFD8) and end-of-image (0xFFD9) markers instead of parsing multipart boundaries. This is more robust because: - No dependency on boundary format quirks - No crossOrigin attribute needed (no canvas tainting issues) - JPEG SOI/EOI markers are reliable frame delimiters per the JPEG spec Also added cancelRecording() cleanup at start of each new recording.
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-3GYuRNvV.js"></script>
|
<script type="module" crossorigin src="/assets/index-C5dzEsTl.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,10 +3,66 @@ 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 EOI = new Uint8Array([0xff, 0xd9])
|
||||||
|
|
||||||
|
function indexOf(haystack: Uint8Array, needle: Uint8Array, from = 0): number {
|
||||||
|
outer: for (let i = from; i <= haystack.length - needle.length; i++) {
|
||||||
|
for (let j = 0; j < needle.length; j++) {
|
||||||
|
if (haystack[i + j] !== needle[j]) continue outer
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture frames from an MJPEG stream by creating an offscreen <img>
|
* Parse a raw MJPEG fetch response by scanning for JPEG SOI/EOI markers.
|
||||||
* (browser-native multipart parsing) and drawing it to a canvas on an interval.
|
* This avoids multipart boundary parsing entirely — just looks for complete JPEG frames.
|
||||||
*/
|
*/
|
||||||
|
async function* parseMjpegByMarkers(
|
||||||
|
response: Response,
|
||||||
|
): AsyncGenerator<{ blob: Blob; timestamp: number }> {
|
||||||
|
if (!response.body) throw new Error('Response body is null')
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
let buffer = new Uint8Array(0)
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
const newBuf = new Uint8Array(buffer.length + value.length)
|
||||||
|
newBuf.set(buffer)
|
||||||
|
newBuf.set(value, buffer.length)
|
||||||
|
buffer = newBuf
|
||||||
|
|
||||||
|
// Extract all complete JPEG frames (SOI ... EOI)
|
||||||
|
while (true) {
|
||||||
|
const soi = indexOf(buffer, SOI)
|
||||||
|
if (soi === -1) break // no frame start in buffer
|
||||||
|
|
||||||
|
const eoi = indexOf(buffer, EOI, soi + 2)
|
||||||
|
if (eoi === -1) break // incomplete frame — wait for more data
|
||||||
|
|
||||||
|
const jpegData = buffer.subarray(soi, eoi + 2)
|
||||||
|
if (jpegData.length > 100) {
|
||||||
|
// sanity check: JPEG frames have at least some content
|
||||||
|
const blob = new Blob([jpegData], { type: 'image/jpeg' })
|
||||||
|
yield { blob, timestamp: Date.now() - startTime }
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = buffer.subarray(eoi + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useRecorder() {
|
export function useRecorder() {
|
||||||
const recorderState: Ref<RecorderState> = ref('idle')
|
const recorderState: Ref<RecorderState> = ref('idle')
|
||||||
const elapsedMs = ref(0)
|
const elapsedMs = ref(0)
|
||||||
@@ -16,96 +72,65 @@ export function useRecorder() {
|
|||||||
let frames: { timestamp: number; blob: Blob }[] = []
|
let frames: { timestamp: number; blob: Blob }[] = []
|
||||||
let startTime = 0
|
let startTime = 0
|
||||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||||
let captureInterval: ReturnType<typeof setInterval> | null = null
|
let abortController: AbortController | null = null
|
||||||
let img: HTMLImageElement | null = null
|
let running = false
|
||||||
let canvas: HTMLCanvasElement | null = null
|
|
||||||
|
|
||||||
const CAPTURE_MS = 100
|
async function startRecording(streamUrl: string) {
|
||||||
|
// Clean up any previous session
|
||||||
|
cancelRecording()
|
||||||
|
|
||||||
function startRecording(streamUrl: string) {
|
|
||||||
frames = []
|
frames = []
|
||||||
frameCount.value = 0
|
frameCount.value = 0
|
||||||
elapsedMs.value = 0
|
elapsedMs.value = 0
|
||||||
recorderError.value = null
|
recorderError.value = null
|
||||||
startTime = Date.now()
|
startTime = Date.now()
|
||||||
recorderState.value = 'recording'
|
recorderState.value = 'recording'
|
||||||
|
running = true
|
||||||
|
|
||||||
timerInterval = setInterval(() => {
|
timerInterval = setInterval(() => {
|
||||||
elapsedMs.value = Date.now() - startTime
|
elapsedMs.value = Date.now() - startTime
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
// Create offscreen <img> — browser decodes MJPEG natively
|
abortController = new AbortController()
|
||||||
img = new Image()
|
|
||||||
img.crossOrigin = 'anonymous'
|
|
||||||
img.src = streamUrl
|
|
||||||
|
|
||||||
canvas = document.createElement('canvas')
|
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!
|
|
||||||
|
|
||||||
img.onerror = () => {
|
|
||||||
recorderError.value = 'Failed to load MJPEG stream for recording'
|
|
||||||
}
|
|
||||||
|
|
||||||
// MJPEG streams never fire "load" reliably — just start capturing after a warm-up delay
|
|
||||||
setTimeout(() => {
|
|
||||||
if (recorderState.value !== 'recording') return
|
|
||||||
|
|
||||||
// Initialize canvas size from what we've received so far
|
|
||||||
canvas!.width = img!.naturalWidth || 640
|
|
||||||
canvas!.height = img!.naturalHeight || 480
|
|
||||||
|
|
||||||
captureInterval = setInterval(() => {
|
|
||||||
if (recorderState.value !== 'recording') return
|
|
||||||
if (!img || !canvas) return
|
|
||||||
|
|
||||||
const w = img.naturalWidth
|
|
||||||
const h = img.naturalHeight
|
|
||||||
if (w && h && (canvas.width !== w || canvas.height !== h)) {
|
|
||||||
canvas.width = w
|
|
||||||
canvas.height = h
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ctx.drawImage(img, 0, 0)
|
const response = await fetch(streamUrl, {
|
||||||
} catch {
|
signal: abortController.signal,
|
||||||
// Canvas may be tainted if CORS is missing — skip silently
|
cache: 'no-store',
|
||||||
return
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.toBlob(
|
for await (const frame of parseMjpegByMarkers(response)) {
|
||||||
(blob) => {
|
if (!running) break
|
||||||
if (!blob || recorderState.value !== 'recording') return
|
|
||||||
frames.push({
|
frames.push({
|
||||||
timestamp: Date.now() - startTime,
|
timestamp: frame.timestamp,
|
||||||
blob,
|
blob: frame.blob,
|
||||||
})
|
})
|
||||||
frameCount.value = frames.length
|
frameCount.value = frames.length
|
||||||
},
|
}
|
||||||
'image/jpeg',
|
} catch (e: any) {
|
||||||
0.85,
|
if (e.name === 'AbortError') return
|
||||||
)
|
recorderError.value = e.message || String(e)
|
||||||
}, CAPTURE_MS)
|
}
|
||||||
}, 500) // warm-up delay for the first frame to arrive
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopRecording(): Promise<Recording | null> {
|
async function stopRecording(): Promise<Recording | null> {
|
||||||
if (recorderState.value !== 'recording') return null
|
if (recorderState.value !== 'recording') return null
|
||||||
|
|
||||||
recorderState.value = 'saving'
|
recorderState.value = 'saving'
|
||||||
|
running = false
|
||||||
|
|
||||||
if (captureInterval) {
|
if (abortController) {
|
||||||
clearInterval(captureInterval)
|
abortController.abort()
|
||||||
captureInterval = null
|
abortController = null
|
||||||
}
|
}
|
||||||
if (timerInterval) {
|
if (timerInterval) {
|
||||||
clearInterval(timerInterval)
|
clearInterval(timerInterval)
|
||||||
timerInterval = null
|
timerInterval = null
|
||||||
}
|
}
|
||||||
if (img) {
|
|
||||||
img.src = ''
|
|
||||||
img = null
|
|
||||||
}
|
|
||||||
canvas = null
|
|
||||||
|
|
||||||
elapsedMs.value = Date.now() - startTime
|
elapsedMs.value = Date.now() - startTime
|
||||||
|
|
||||||
@@ -134,19 +159,15 @@ export function useRecorder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancelRecording() {
|
function cancelRecording() {
|
||||||
if (captureInterval) {
|
running = false
|
||||||
clearInterval(captureInterval)
|
if (abortController) {
|
||||||
captureInterval = null
|
abortController.abort()
|
||||||
|
abortController = null
|
||||||
}
|
}
|
||||||
if (timerInterval) {
|
if (timerInterval) {
|
||||||
clearInterval(timerInterval)
|
clearInterval(timerInterval)
|
||||||
timerInterval = null
|
timerInterval = null
|
||||||
}
|
}
|
||||||
if (img) {
|
|
||||||
img.src = ''
|
|
||||||
img = null
|
|
||||||
}
|
|
||||||
canvas = null
|
|
||||||
frames = []
|
frames = []
|
||||||
frameCount.value = 0
|
frameCount.value = 0
|
||||||
elapsedMs.value = 0
|
elapsedMs.value = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user