fix: use native img tag for MJPEG live display

Live view now uses <img :src='streamUrl'> (browser-native MJPEG
support, same as the working test HTML). The fetch-based multipart
parser is kept only for recording frame capture. Also fixed
multipart boundary parsing to correctly handle boundary values
that already include leading dashes.

- LiveView receives streamUrl string, passes it directly to <img>
- useMjpegStream simplified to URL + state management
- useRecorder manages its own fetch stream independently
- RecorderControls shows recorder-specific errors
This commit is contained in:
2026-08-01 16:04:20 +08:00
parent 97e6b4c056
commit 6bbe83e1f1
5 changed files with 206 additions and 222 deletions
+11 -13
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import { config } from './config'
import { useMjpegStream } from './composables/useMjpegStream'
import { useRecorder } from './composables/useRecorder'
import LiveView from './components/LiveView.vue'
@@ -8,25 +7,22 @@ import RecorderControls from './components/RecorderControls.vue'
import PlaybackPanel from './components/PlaybackPanel.vue'
import ConfigPanel from './components/ConfigPanel.vue'
// ---- Stream ----
const { isLive, error, frameUrl, start, stop, setOnFrame } = useMjpegStream()
// ---- Stream (native <img> — no CORS issues) ----
const { streamUrl, isLive, error, start, stop } = useMjpegStream()
// ---- Recorder ----
// ---- Recorder (fetch-based frame capture) ----
const {
recorderState,
elapsedMs,
frameCount,
onFrame,
recorderError,
startRecording,
stopRecording,
cancelRecording,
formatElapsed,
} = useRecorder()
// Wire recorder to stream frames
setOnFrame(onFrame)
// ---- Playback panel ref (to refresh list after save) ----
// ---- Playback panel ref ----
const playbackRef = ref<InstanceType<typeof PlaybackPanel> | null>(null)
// ---- Active tab ----
@@ -38,13 +34,13 @@ function handleConnect(url: string) {
start(url)
}
async function handleStartRecording() {
startRecording()
function handleStartRecording() {
if (!streamUrl.value) return
startRecording(streamUrl.value)
}
async function handleStopRecording() {
const recording = await stopRecording()
// Refresh playback list
playbackRef.value?.loadRecordings()
if (recording) {
activeTab.value = 'playback'
@@ -59,6 +55,7 @@ const elapsedFormatted = computed(() => formatElapsed(elapsedMs.value))
onUnmounted(() => {
stop()
cancelRecording()
})
</script>
@@ -94,12 +91,13 @@ onUnmounted(() => {
<!-- Tab: Live View -->
<section v-show="activeTab === 'live'" class="tab-content">
<LiveView :frame-url="frameUrl" :is-live="isLive" :error="error" />
<LiveView :stream-url="streamUrl" :is-live="isLive" :error="error" />
<RecorderControls
:state="recorderState"
:elapsed="elapsedFormatted"
:frame-count="frameCount"
:is-live="isLive"
:error="recorderError"
@start="handleStartRecording"
@stop="handleStopRecording"
@cancel="handleCancelRecording"
+6 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
defineProps<{
frameUrl: string | null
streamUrl: string | null
isLive: boolean
error: string | null
}>()
@@ -9,17 +9,18 @@ defineProps<{
<template>
<div class="live-view">
<div class="video-container">
<!-- MJPEG stream displayed via frame URL (canvas-based) -->
<!-- Native <img> handles MJPEG (multipart/x-mixed-replace) natively — no CORS issues -->
<img
v-if="frameUrl"
:src="frameUrl"
v-if="streamUrl && isLive"
:key="streamUrl"
:src="streamUrl"
alt="MJPEG Stream"
class="video-frame"
/>
<!-- Placeholder when no stream -->
<div v-else class="video-placeholder">
<span v-if="error" class="error-text">{{ error }}</span>
<span v-else>Stream not started</span>
<span v-else>Stream not started — configure and apply a stream URL above</span>
</div>
<!-- Live indicator -->
@@ -6,6 +6,7 @@ defineProps<{
elapsed: string
frameCount: number
isLive: boolean
error: string | null
}>()
const emit = defineEmits<{
@@ -55,6 +56,11 @@ const emit = defineEmits<{
<span class="rec-time">{{ elapsed }}</span>
<span class="rec-frames">{{ frameCount }} frames</span>
</div>
<!-- Recorder fetch error -->
<div v-if="error" class="recorder-error">
⚠ {{ error }}
</div>
</div>
</template>
@@ -172,4 +178,14 @@ const emit = defineEmits<{
.rec-frames {
color: #999;
}
.recorder-error {
font-size: 0.85rem;
color: #f39c12;
background: rgba(243, 156, 18, 0.1);
padding: 6px 12px;
border-radius: 4px;
max-width: 100%;
word-break: break-all;
}
</style>
+8 -189
View File
@@ -1,203 +1,22 @@
/**
* Composable to fetch and parse an MJPEG stream.
*
* Uses the Fetch API with a ReadableStream to parse multipart/x-mixed-replace
* boundaries and extract individual JPEG frames.
*/
import { ref, type Ref } from 'vue'
export interface MjpegFrame {
blob: Blob
/** Elapsed ms since the stream started */
timestamp: number
}
export function useMjpegStream() {
const streamUrl: Ref<string | null> = ref(null)
const isLive = ref(false)
const error: Ref<string | null> = ref(null)
/** Latest frame Blob URL — bind to <img src> for live display */
const frameUrl: Ref<string | null> = ref(null)
let abortController: AbortController | null = null
let startTime = 0
let oldUrl: string | null = null
/** Callback invoked for every parsed frame (used by recorder) */
let onFrame: ((frame: MjpegFrame) => void) | null = null
function setOnFrame(cb: ((frame: MjpegFrame) => void) | null) {
onFrame = cb
}
/**
* Parse a multipart/x-mixed-replace stream and yield JPEG blobs.
*/
async function* parseStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
boundary: string,
): AsyncGenerator<MjpegFrame> {
let buffer = new Uint8Array(0)
const encoder = new TextEncoder()
const boundaryStr = `--${boundary}`
const boundaryBytes = encoder.encode(boundaryStr)
const endBoundaryStr = `--${boundary}--`
const endBoundaryBytes = encoder.encode(endBoundaryStr)
const headerEnd = encoder.encode('\r\n\r\n')
const nl = encoder.encode('\r\n')
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
}
let streamEnded = false
try {
while (!streamEnded) {
const { done, value } = await reader.read()
if (done) break
// Append new data to buffer
const newBuf = new Uint8Array(buffer.length + value.length)
newBuf.set(buffer)
newBuf.set(value, buffer.length)
buffer = newBuf
// Keep parsing frames while boundaries exist
while (true) {
// Check for end boundary first
const endIdx = indexOf(buffer, endBoundaryBytes)
if (endIdx !== -1) {
streamEnded = true
break
}
// Find first boundary
const bIdx = indexOf(buffer, boundaryBytes)
if (bIdx === -1) break
if (bIdx === endIdx) break
// Find the next boundary after this one
const nextBdry = indexOf(buffer, boundaryBytes, bIdx + boundaryBytes.length)
if (nextBdry === -1) break // incomplete frame — wait for more data
// Extract content between bIdx and nextBdry
const part = buffer.subarray(bIdx, nextBdry)
// Find end of headers (\r\n\r\n)
const headerEndIdx = indexOf(part, headerEnd)
if (headerEndIdx === -1) {
// Malformed part — skip
buffer = buffer.subarray(nextBdry)
continue
}
const bodyStart = headerEndIdx + headerEnd.length
// Strip trailing \r\n before the next boundary
let bodyEnd = part.length
if (
bodyEnd >= 2 &&
part[bodyEnd - 2] === nl[0] &&
part[bodyEnd - 1] === nl[1]
) {
bodyEnd -= 2
}
const jpegData = part.subarray(bodyStart, bodyEnd)
const blob = new Blob([jpegData], { type: 'image/jpeg' })
const elapsed = Date.now() - startTime
yield { blob, timestamp: elapsed }
// Advance buffer past this boundary
buffer = buffer.subarray(nextBdry)
}
}
} finally {
reader.releaseLock()
}
}
async function start(url: string) {
stop() // clean up any existing stream
error.value = null
function start(url: string) {
stop()
streamUrl.value = url
isLive.value = true
startTime = Date.now()
abortController = new AbortController()
try {
const response = await fetch(url, {
signal: abortController.signal,
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const contentType = response.headers.get('content-type') || ''
const boundaryMatch = contentType.match(/boundary=("?)([^";]+)\1/)
if (!boundaryMatch) {
throw new Error('Response is not a multipart MJPEG stream (no boundary in Content-Type)')
}
const boundary = boundaryMatch[2]
if (!boundary) {
throw new Error('Could not parse boundary from Content-Type header')
}
if (!response.body) {
throw new Error('Response body is null — cannot read stream')
}
const reader = response.body.getReader()
for await (const frame of parseStream(reader, boundary)) {
// Update live preview
const url = URL.createObjectURL(frame.blob)
if (oldUrl) URL.revokeObjectURL(oldUrl)
oldUrl = url
frameUrl.value = url
// Notify recorder
if (onFrame) {
onFrame(frame)
}
}
} catch (e: any) {
if (e.name === 'AbortError') return
error.value = e.message || String(e)
} finally {
isLive.value = false
}
error.value = null
}
function stop() {
if (abortController) {
abortController.abort()
abortController = null
}
if (oldUrl) {
URL.revokeObjectURL(oldUrl)
oldUrl = null
}
frameUrl.value = null
streamUrl.value = null
isLive.value = false
error.value = null
}
return {
isLive,
error,
frameUrl,
start,
stop,
setOnFrame,
}
return { streamUrl, isLive, error, start, stop }
}
+165 -15
View File
@@ -1,46 +1,191 @@
/**
* Composable for recording MJPEG frames and saving them to IndexedDB.
*/
import { ref, type Ref } from 'vue'
import { saveRecording, type Frame, type Recording } from './useStorage'
export type RecorderState = 'idle' | 'recording' | 'saving'
/**
* Parse an MJPEG stream from a fetch Response into individual JPEG frames.
*/
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 startTime = 0
let timerInterval: ReturnType<typeof setInterval> | null = null
let abortController: AbortController | null = null
function onFrame(frame: { blob: Blob; timestamp: number }) {
if (recorderState.value !== 'recording') return
frames.push({
timestamp: frame.timestamp,
blob: frame.blob,
})
frameCount.value = frames.length
}
function startRecording() {
async function startRecording(streamUrl: string) {
frames = []
frameCount.value = 0
elapsedMs.value = 0
recorderError.value = null
startTime = Date.now()
recorderState.value = 'recording'
timerInterval = setInterval(() => {
elapsedMs.value = Date.now() - startTime
}, 100)
abortController = new AbortController()
// Start fetching in background
captureStream(streamUrl)
}
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
}
}
async function stopRecording(): Promise<Recording | null> {
if (recorderState.value !== 'recording') return null
recorderState.value = 'saving'
// Abort the fetch
if (abortController) {
abortController.abort()
abortController = null
}
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
@@ -72,6 +217,10 @@ export function useRecorder() {
}
function cancelRecording() {
if (abortController) {
abortController.abort()
abortController = null
}
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
@@ -79,6 +228,7 @@ export function useRecorder() {
frames = []
frameCount.value = 0
elapsedMs.value = 0
recorderError.value = null
recorderState.value = 'idle'
}
@@ -94,7 +244,7 @@ export function useRecorder() {
recorderState,
elapsedMs,
frameCount,
onFrame,
recorderError,
startRecording,
stopRecording,
cancelRecording,