fix: use canvas capture for recording, remove cancel button
Recording now creates an offscreen <img> (browser-native MJPEG decoding) and captures frames via canvas.toBlob() at 100ms intervals. This replaces the broken fetch-based multipart parser. - useRecorder: img+canvas capture instead of fetch+ReadableStream - RecorderControls: removed Cancel button, only Record/Stop remain - App.vue: removed cancel wiring
This commit is contained in:
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-BqiYluth.js"></script>
|
<script type="module" crossorigin src="/assets/index-3GYuRNvV.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CJmYwh3k.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DuJPD2XK.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+8
-14
@@ -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 (fetch-based frame capture) ----
|
// ---- Recorder (canvas-based frame capture) ----
|
||||||
const {
|
const {
|
||||||
recorderState,
|
recorderState,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
@@ -18,7 +18,6 @@ const {
|
|||||||
recorderError,
|
recorderError,
|
||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
cancelRecording,
|
|
||||||
formatElapsed,
|
formatElapsed,
|
||||||
} = useRecorder()
|
} = useRecorder()
|
||||||
|
|
||||||
@@ -39,23 +38,19 @@ function handleStartRecording() {
|
|||||||
startRecording(streamUrl.value)
|
startRecording(streamUrl.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStopRecording() {
|
function handleStopRecording() {
|
||||||
const recording = await stopRecording()
|
stopRecording().then((recording) => {
|
||||||
playbackRef.value?.loadRecordings()
|
playbackRef.value?.loadRecordings()
|
||||||
if (recording) {
|
if (recording) {
|
||||||
activeTab.value = 'playback'
|
activeTab.value = 'playback'
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
function handleCancelRecording() {
|
|
||||||
cancelRecording()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsedFormatted = computed(() => formatElapsed(elapsedMs.value))
|
const elapsedFormatted = computed(() => formatElapsed(elapsedMs.value))
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stop()
|
stop()
|
||||||
cancelRecording()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -100,7 +95,6 @@ onUnmounted(() => {
|
|||||||
:error="recorderError"
|
:error="recorderError"
|
||||||
@start="handleStartRecording"
|
@start="handleStartRecording"
|
||||||
@stop="handleStopRecording"
|
@stop="handleStopRecording"
|
||||||
@cancel="handleCancelRecording"
|
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
start: []
|
start: []
|
||||||
stop: []
|
stop: []
|
||||||
cancel: []
|
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -31,17 +30,16 @@ const emit = defineEmits<{
|
|||||||
Record
|
Record
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Recording: show Stop and Cancel -->
|
<!-- Recording: show Stop -->
|
||||||
<template v-if="state === 'recording'">
|
<button
|
||||||
<button class="btn btn-stop" @click="emit('stop')" title="Stop and save recording">
|
v-if="state === 'recording'"
|
||||||
<span class="btn-icon">⏹</span>
|
class="btn btn-stop"
|
||||||
Stop & Save
|
@click="emit('stop')"
|
||||||
</button>
|
title="Stop and save recording"
|
||||||
<button class="btn btn-cancel" @click="emit('cancel')" title="Cancel recording">
|
>
|
||||||
<span class="btn-icon">✕</span>
|
<span class="btn-icon">⏹</span>
|
||||||
Cancel
|
Stop & Save
|
||||||
</button>
|
</button>
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Saving -->
|
<!-- Saving -->
|
||||||
<button v-if="state === 'saving'" class="btn btn-saving" disabled>
|
<button v-if="state === 'saving'" class="btn btn-saving" disabled>
|
||||||
@@ -57,7 +55,7 @@ const emit = defineEmits<{
|
|||||||
<span class="rec-frames">{{ frameCount }} frames</span>
|
<span class="rec-frames">{{ frameCount }} frames</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recorder fetch error -->
|
<!-- Recorder error -->
|
||||||
<div v-if="error" class="recorder-error">
|
<div v-if="error" class="recorder-error">
|
||||||
⚠ {{ error }}
|
⚠ {{ error }}
|
||||||
</div>
|
</div>
|
||||||
@@ -121,15 +119,6 @@ const emit = defineEmits<{
|
|||||||
background: #c0392b;
|
background: #c0392b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
background: #555;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel:hover {
|
|
||||||
background: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-saving {
|
.btn-saving {
|
||||||
background: #666;
|
background: #666;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
+76
-154
@@ -1,137 +1,28 @@
|
|||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
import { saveRecording, type Frame, type Recording } from './useStorage'
|
import { saveRecording, type Recording } from './useStorage'
|
||||||
|
|
||||||
export type RecorderState = 'idle' | 'recording' | 'saving'
|
export type RecorderState = 'idle' | 'recording' | 'saving'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an MJPEG stream from a fetch Response into individual JPEG frames.
|
* Capture frames from an MJPEG stream by creating an offscreen <img>
|
||||||
|
* (browser-native multipart parsing) and drawing it to a canvas on an interval.
|
||||||
*/
|
*/
|
||||||
async function* parseMjpegStream(
|
|
||||||
response: Response,
|
|
||||||
): AsyncGenerator<{ blob: Blob; timestamp: number }> {
|
|
||||||
const contentType = response.headers.get('content-type') || ''
|
|
||||||
const boundaryMatch = contentType.match(/boundary=("?)([^";]+)\1/)
|
|
||||||
if (!boundaryMatch?.[2]) {
|
|
||||||
throw new Error('No boundary found in Content-Type: ' + contentType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RFC 2046: delimiter = "--" + boundary_param_value
|
|
||||||
const boundary = '--' + boundaryMatch[2]
|
|
||||||
const endBoundary = boundary + '--'
|
|
||||||
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const boundaryBytes = encoder.encode(boundary)
|
|
||||||
const endBoundaryBytes = encoder.encode(endBoundary)
|
|
||||||
const headerEndBytes = encoder.encode('\r\n\r\n')
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error('Response body is null')
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader()
|
|
||||||
let buffer = new Uint8Array(0)
|
|
||||||
const startTime = Date.now()
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function findBoundary(buf: Uint8Array): { idx: number; len: number } | null {
|
|
||||||
const endIdx = indexOf(buf, endBoundaryBytes)
|
|
||||||
if (endIdx !== -1) {
|
|
||||||
return { idx: endIdx, len: endBoundaryBytes.length }
|
|
||||||
}
|
|
||||||
const bIdx = indexOf(buf, boundaryBytes)
|
|
||||||
if (bIdx !== -1) {
|
|
||||||
return { idx: bIdx, len: boundaryBytes.length }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let yielded = 0
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
// Append to buffer
|
|
||||||
const newBuf = new Uint8Array(buffer.length + value.length)
|
|
||||||
newBuf.set(buffer)
|
|
||||||
newBuf.set(value, buffer.length)
|
|
||||||
buffer = newBuf
|
|
||||||
|
|
||||||
// Parse all complete parts
|
|
||||||
while (true) {
|
|
||||||
// Find first boundary
|
|
||||||
const first = findBoundary(buffer)
|
|
||||||
if (!first) break
|
|
||||||
|
|
||||||
// Is it the end boundary?
|
|
||||||
if (first.len === endBoundaryBytes.length) {
|
|
||||||
return // stream ended
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the next boundary after this one
|
|
||||||
const next = findBoundary(buffer.slice(first.idx + first.len))
|
|
||||||
if (!next) break // incomplete — wait for more data
|
|
||||||
|
|
||||||
const partStart = first.idx + first.len
|
|
||||||
const partEnd = first.idx + first.len + next.idx
|
|
||||||
|
|
||||||
const part = buffer.subarray(partStart, partEnd)
|
|
||||||
|
|
||||||
// Find end of headers
|
|
||||||
const headerIdx = indexOf(part, headerEndBytes)
|
|
||||||
if (headerIdx === -1) {
|
|
||||||
// Skip malformed part
|
|
||||||
buffer = buffer.subarray(first.idx + first.len)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyStart = headerIdx + headerEndBytes.length
|
|
||||||
|
|
||||||
// Strip trailing CRLF before next boundary
|
|
||||||
let bodyEnd = part.length
|
|
||||||
if (bodyEnd >= 2 && part[bodyEnd - 2] === 13 && part[bodyEnd - 1] === 10) {
|
|
||||||
bodyEnd -= 2
|
|
||||||
}
|
|
||||||
|
|
||||||
const jpegData = part.subarray(bodyStart, bodyEnd)
|
|
||||||
if (jpegData.length > 0) {
|
|
||||||
const blob = new Blob([jpegData], { type: 'image/jpeg' })
|
|
||||||
const elapsed = Date.now() - startTime
|
|
||||||
yielded++
|
|
||||||
yield { blob, timestamp: elapsed }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advance buffer past this part
|
|
||||||
buffer = buffer.subarray(partEnd + next.len)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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)
|
||||||
const frameCount = ref(0)
|
const frameCount = ref(0)
|
||||||
const recorderError: Ref<string | null> = ref(null)
|
const recorderError: Ref<string | null> = ref(null)
|
||||||
|
|
||||||
let frames: Frame[] = []
|
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 abortController: AbortController | null = null
|
let captureInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
let img: HTMLImageElement | null = null
|
||||||
|
let canvas: HTMLCanvasElement | null = null
|
||||||
|
|
||||||
async function startRecording(streamUrl: string) {
|
const CAPTURE_MS = 100
|
||||||
|
|
||||||
|
function startRecording(streamUrl: string) {
|
||||||
frames = []
|
frames = []
|
||||||
frameCount.value = 0
|
frameCount.value = 0
|
||||||
elapsedMs.value = 0
|
elapsedMs.value = 0
|
||||||
@@ -143,36 +34,58 @@ export function useRecorder() {
|
|||||||
elapsedMs.value = Date.now() - startTime
|
elapsedMs.value = Date.now() - startTime
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
abortController = new AbortController()
|
// Create offscreen <img> — browser decodes MJPEG natively
|
||||||
|
img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.src = streamUrl
|
||||||
|
|
||||||
// Start fetching in background
|
canvas = document.createElement('canvas')
|
||||||
captureStream(streamUrl)
|
const ctx = canvas.getContext('2d', { willReadFrequently: true })!
|
||||||
}
|
|
||||||
|
|
||||||
async function captureStream(url: string) {
|
img.onerror = () => {
|
||||||
try {
|
recorderError.value = 'Failed to load MJPEG stream for recording'
|
||||||
const response = await fetch(url, {
|
|
||||||
signal: abortController!.signal,
|
|
||||||
cache: 'no-store',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const frame of parseMjpegStream(response)) {
|
|
||||||
if (recorderState.value !== 'recording') break
|
|
||||||
frames.push({
|
|
||||||
timestamp: frame.timestamp,
|
|
||||||
blob: frame.blob,
|
|
||||||
})
|
|
||||||
frameCount.value = frames.length
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e.name === 'AbortError') return
|
|
||||||
recorderError.value = e.message || String(e)
|
|
||||||
// Don't stop recording on error — let user see the error and decide
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
} catch {
|
||||||
|
// Canvas may be tainted if CORS is missing — skip silently
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (!blob || recorderState.value !== 'recording') return
|
||||||
|
frames.push({
|
||||||
|
timestamp: Date.now() - startTime,
|
||||||
|
blob,
|
||||||
|
})
|
||||||
|
frameCount.value = frames.length
|
||||||
|
},
|
||||||
|
'image/jpeg',
|
||||||
|
0.85,
|
||||||
|
)
|
||||||
|
}, CAPTURE_MS)
|
||||||
|
}, 500) // warm-up delay for the first frame to arrive
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopRecording(): Promise<Recording | null> {
|
async function stopRecording(): Promise<Recording | null> {
|
||||||
@@ -180,16 +93,20 @@ export function useRecorder() {
|
|||||||
|
|
||||||
recorderState.value = 'saving'
|
recorderState.value = 'saving'
|
||||||
|
|
||||||
// Abort the fetch
|
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
|
||||||
|
|
||||||
if (frames.length === 0) {
|
if (frames.length === 0) {
|
||||||
@@ -217,14 +134,19 @@ export function useRecorder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancelRecording() {
|
function cancelRecording() {
|
||||||
if (abortController) {
|
if (captureInterval) {
|
||||||
abortController.abort()
|
clearInterval(captureInterval)
|
||||||
abortController = null
|
captureInterval = 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