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">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MJPEG Stream Viewer</title>
|
||||
<script type="module" crossorigin src="/assets/index-BqiYluth.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CJmYwh3k.css">
|
||||
<script type="module" crossorigin src="/assets/index-3GYuRNvV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DuJPD2XK.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+8
-14
@@ -10,7 +10,7 @@ import ConfigPanel from './components/ConfigPanel.vue'
|
||||
// ---- Stream (native <img> — no CORS issues) ----
|
||||
const { streamUrl, isLive, error, start, stop } = useMjpegStream()
|
||||
|
||||
// ---- Recorder (fetch-based frame capture) ----
|
||||
// ---- Recorder (canvas-based frame capture) ----
|
||||
const {
|
||||
recorderState,
|
||||
elapsedMs,
|
||||
@@ -18,7 +18,6 @@ const {
|
||||
recorderError,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
formatElapsed,
|
||||
} = useRecorder()
|
||||
|
||||
@@ -39,23 +38,19 @@ function handleStartRecording() {
|
||||
startRecording(streamUrl.value)
|
||||
}
|
||||
|
||||
async function handleStopRecording() {
|
||||
const recording = await stopRecording()
|
||||
playbackRef.value?.loadRecordings()
|
||||
if (recording) {
|
||||
activeTab.value = 'playback'
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelRecording() {
|
||||
cancelRecording()
|
||||
function handleStopRecording() {
|
||||
stopRecording().then((recording) => {
|
||||
playbackRef.value?.loadRecordings()
|
||||
if (recording) {
|
||||
activeTab.value = 'playback'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const elapsedFormatted = computed(() => formatElapsed(elapsedMs.value))
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
cancelRecording()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -100,7 +95,6 @@ onUnmounted(() => {
|
||||
:error="recorderError"
|
||||
@start="handleStartRecording"
|
||||
@stop="handleStopRecording"
|
||||
@cancel="handleCancelRecording"
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ defineProps<{
|
||||
const emit = defineEmits<{
|
||||
start: []
|
||||
stop: []
|
||||
cancel: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -31,17 +30,16 @@ const emit = defineEmits<{
|
||||
Record
|
||||
</button>
|
||||
|
||||
<!-- Recording: show Stop and Cancel -->
|
||||
<template v-if="state === 'recording'">
|
||||
<button class="btn btn-stop" @click="emit('stop')" title="Stop and save recording">
|
||||
<span class="btn-icon">⏹</span>
|
||||
Stop & Save
|
||||
</button>
|
||||
<button class="btn btn-cancel" @click="emit('cancel')" title="Cancel recording">
|
||||
<span class="btn-icon">✕</span>
|
||||
Cancel
|
||||
</button>
|
||||
</template>
|
||||
<!-- Recording: show Stop -->
|
||||
<button
|
||||
v-if="state === 'recording'"
|
||||
class="btn btn-stop"
|
||||
@click="emit('stop')"
|
||||
title="Stop and save recording"
|
||||
>
|
||||
<span class="btn-icon">⏹</span>
|
||||
Stop & Save
|
||||
</button>
|
||||
|
||||
<!-- Saving -->
|
||||
<button v-if="state === 'saving'" class="btn btn-saving" disabled>
|
||||
@@ -57,7 +55,7 @@ const emit = defineEmits<{
|
||||
<span class="rec-frames">{{ frameCount }} frames</span>
|
||||
</div>
|
||||
|
||||
<!-- Recorder fetch error -->
|
||||
<!-- Recorder error -->
|
||||
<div v-if="error" class="recorder-error">
|
||||
⚠ {{ error }}
|
||||
</div>
|
||||
@@ -121,15 +119,6 @@ const emit = defineEmits<{
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.btn-saving {
|
||||
background: #666;
|
||||
color: #fff;
|
||||
|
||||
+76
-154
@@ -1,137 +1,28 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const recorderState: Ref<RecorderState> = ref('idle')
|
||||
const elapsedMs = ref(0)
|
||||
const frameCount = ref(0)
|
||||
const recorderError: Ref<string | null> = ref(null)
|
||||
|
||||
let frames: Frame[] = []
|
||||
let frames: { timestamp: number; blob: Blob }[] = []
|
||||
let startTime = 0
|
||||
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 = []
|
||||
frameCount.value = 0
|
||||
elapsedMs.value = 0
|
||||
@@ -143,36 +34,58 @@ export function useRecorder() {
|
||||
elapsedMs.value = Date.now() - startTime
|
||||
}, 100)
|
||||
|
||||
abortController = new AbortController()
|
||||
// Create offscreen <img> — browser decodes MJPEG natively
|
||||
img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = streamUrl
|
||||
|
||||
// Start fetching in background
|
||||
captureStream(streamUrl)
|
||||
}
|
||||
canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!
|
||||
|
||||
async function captureStream(url: string) {
|
||||
try {
|
||||
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
|
||||
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 {
|
||||
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> {
|
||||
@@ -180,16 +93,20 @@ export function useRecorder() {
|
||||
|
||||
recorderState.value = 'saving'
|
||||
|
||||
// Abort the fetch
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
abortController = null
|
||||
if (captureInterval) {
|
||||
clearInterval(captureInterval)
|
||||
captureInterval = null
|
||||
}
|
||||
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
if (img) {
|
||||
img.src = ''
|
||||
img = null
|
||||
}
|
||||
canvas = null
|
||||
|
||||
elapsedMs.value = Date.now() - startTime
|
||||
|
||||
if (frames.length === 0) {
|
||||
@@ -217,14 +134,19 @@ export function useRecorder() {
|
||||
}
|
||||
|
||||
function cancelRecording() {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
abortController = null
|
||||
if (captureInterval) {
|
||||
clearInterval(captureInterval)
|
||||
captureInterval = null
|
||||
}
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
if (img) {
|
||||
img.src = ''
|
||||
img = null
|
||||
}
|
||||
canvas = null
|
||||
frames = []
|
||||
frameCount.value = 0
|
||||
elapsedMs.value = 0
|
||||
|
||||
Reference in New Issue
Block a user