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
+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