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:
2026-08-01 16:20:48 +08:00
parent 386d8d5852
commit 88caa1e976
7 changed files with 99 additions and 196 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
View File
@@ -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>
+11 -22
View File
@@ -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 &amp; 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 &amp; 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
View File
@@ -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