feat: use MediaRecorder + canvas.captureStream() for MP4/WebM output

Recording now uses the browser's hardware video encoder:
- JPEG frames drawn to offscreen canvas → canvas.captureStream(30fps)
- MediaRecorder encodes to MP4 (preferred) or WebM (fallback)
- Stop triggers download of standard video file

Playback uses native <video> element — no custom parsing needed.
Massively simplified: removed packFrames/unpackFrames, no more custom
binary format.
This commit is contained in:
2026-08-01 20:29:32 +08:00
parent d2ecb1a4ed
commit 9cb9045f87
9 changed files with 176 additions and 260 deletions
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
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"> <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-CHMLXxlv.js"></script> <script type="module" crossorigin src="/assets/index-aL8cGzGF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Deon0U_J.css"> <link rel="stylesheet" crossorigin href="/assets/index-C8d4acis.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+1 -1
View File
@@ -92,7 +92,7 @@ onUnmounted(() => {
<!-- Tab: Playback --> <!-- Tab: Playback -->
<section v-show="activeTab === 'playback'" class="tab-content"> <section v-show="activeTab === 'playback'" class="tab-content">
<PlaybackPanel ref="playbackRef" /> <PlaybackPanel />
</section> </section>
</main> </main>
</div> </div>
+81 -175
View File
@@ -1,171 +1,75 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onUnmounted } from 'vue' import { ref } from 'vue'
import { unpackFrames, type Frame, type RecordingFile } from '../composables/useStorage'
const canvasRef = ref<HTMLCanvasElement | null>(null) const videoRef = ref<HTMLVideoElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null) const fileInputRef = ref<HTMLInputElement | null>(null)
const videoUrl = ref<string | null>(null)
const fileName = ref('')
const playing = ref(false)
const playbackTime = ref('00:00.00')
const playbackProgress = ref(0) const playbackProgress = ref(0)
const playbackDuration = ref('00:00.00')
const playbackState = ref<'idle' | 'playing' | 'paused'>('idle')
const currentFile = ref<RecordingFile | null>(null) function formatTime(seconds: number): string {
const s = Math.max(0, seconds)
let activeFrames: Frame[] = [] const min = Math.floor(s / 60)
let activeDuration = 0 const sec = Math.floor(s % 60)
let currentFrameIndex = 0 const centis = Math.floor((s % 1) * 100)
let playbackStartTime = 0
let totalPausedTime = 0
let pausedAt = 0
let rafId = 0
function formatDuration(ms: number): string {
const totalSec = Math.floor(ms / 1000)
const min = Math.floor(totalSec / 60)
const sec = totalSec % 60
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
}
function formatElapsed(ms: number): string {
const totalSec = Math.floor(Math.max(0, ms) / 1000)
const min = Math.floor(totalSec / 60)
const sec = totalSec % 60
const centis = Math.floor((Math.max(0, ms) % 1000) / 10)
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(centis).padStart(2, '0')}` return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(centis).padStart(2, '0')}`
} }
function getCanvasCtx(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null { function handleFileSelect(event: Event) {
const canvas = canvasRef.value
if (!canvas) return null
const ctx = canvas.getContext('2d')
if (!ctx) return null
return { canvas, ctx }
}
function renderPlaybackFrame(
frames: Frame[],
duration: number,
index: number,
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
) {
if (playbackState.value !== 'playing') return
if (index >= frames.length) {
stopPlayback()
return
}
currentFrameIndex = index
const frame = frames[index]!
const elapsed = performance.now() - playbackStartTime - totalPausedTime
playbackProgress.value = Math.min((elapsed / duration) * 100, 100)
playbackDuration.value = formatElapsed(elapsed)
const img = new Image()
const url = URL.createObjectURL(frame.blob)
img.onload = () => {
URL.revokeObjectURL(url)
if (canvas.width !== img.width || canvas.height !== img.height) {
canvas.width = img.width
canvas.height = img.height
}
ctx.drawImage(img, 0, 0)
const nextIndex = index + 1
if (nextIndex < frames.length) {
const nextFrame = frames[nextIndex]!
const delay = nextFrame.timestamp - frame.timestamp
rafId = window.setTimeout(
() => renderPlaybackFrame(frames, duration, nextIndex, canvas, ctx),
Math.max(delay, 16),
)
} else {
rafId = window.setTimeout(() => stopPlayback(), 500)
}
}
img.src = url
}
async function startPlayback(frames: Frame[], duration: number) {
stopPlayback()
const c = getCanvasCtx()
if (!c) return
activeFrames = frames
activeDuration = duration
playbackState.value = 'playing'
currentFrameIndex = 0
totalPausedTime = 0
playbackStartTime = performance.now()
renderPlaybackFrame(activeFrames, activeDuration, 0, c.canvas, c.ctx)
}
function pausePlayback() {
if (playbackState.value !== 'playing') return
playbackState.value = 'paused'
pausedAt = performance.now()
if (rafId) {
clearTimeout(rafId)
rafId = 0
}
}
function resumePlayback() {
if (playbackState.value !== 'paused') return
const c = getCanvasCtx()
if (!c) return
totalPausedTime += performance.now() - pausedAt
playbackState.value = 'playing'
renderPlaybackFrame(activeFrames, activeDuration, currentFrameIndex, c.canvas, c.ctx)
}
function stopPlayback() {
playbackState.value = 'idle'
playbackProgress.value = 0
playbackDuration.value = '00:00.00'
currentFrameIndex = 0
activeFrames = []
activeDuration = 0
if (rafId) {
clearTimeout(rafId)
rafId = 0
}
const canvas = canvasRef.value
if (canvas) {
const ctx = canvas.getContext('2d')
ctx?.clearRect(0, 0, canvas.width, canvas.height)
}
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const file = input.files?.[0] const file = input.files?.[0]
if (!file) return if (!file) return
stopPlayback() // Revoke previous URL
if (videoUrl.value) {
try { URL.revokeObjectURL(videoUrl.value)
const frames = await unpackFrames(file)
currentFile.value = {
name: file.name,
duration: frames.length > 0 ? frames[frames.length - 1]!.timestamp : 0,
frameCount: frames.length,
data: file,
}
await startPlayback(frames, currentFile.value.duration)
} catch (e: any) {
currentFile.value = null
alert(`Failed to load recording: ${e.message || e}`)
} }
// Reset input so the same file can be re-selected stopPlayback()
fileName.value = file.name
videoUrl.value = URL.createObjectURL(file)
// Reset input
input.value = '' input.value = ''
} }
onUnmounted(() => { function play() {
stopPlayback() const v = videoRef.value
}) if (!v) return
v.play()
playing.value = true
}
function pause() {
videoRef.value?.pause()
playing.value = false
}
function stopPlayback() {
const v = videoRef.value
if (v) {
v.pause()
v.currentTime = 0
}
playing.value = false
playbackTime.value = '00:00.00'
playbackProgress.value = 0
}
function onTimeUpdate() {
const v = videoRef.value
if (!v) return
playbackTime.value = formatTime(v.currentTime)
if (v.duration) {
playbackProgress.value = (v.currentTime / v.duration) * 100
}
}
function onEnded() {
playing.value = false
}
</script> </script>
<template> <template>
@@ -177,42 +81,48 @@ onUnmounted(() => {
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file" type="file"
accept=".mjpeg" accept=".mp4,.webm,video/mp4,video/webm"
class="file-input-hidden" class="file-input-hidden"
@change="handleFileSelect" @change="handleFileSelect"
/> />
<button class="btn btn-load" @click="fileInputRef?.click()"> <button class="btn btn-load" @click="fileInputRef?.click()">
📂 Load Recording File 📂 Load Video
</button> </button>
<span v-if="currentFile" class="file-info"> <span v-if="fileName" class="file-info">{{ fileName }}</span>
{{ currentFile.name }} · {{ currentFile.frameCount }} frames · {{ formatDuration(currentFile.duration) }} <span v-else class="file-hint">Select a recorded video file</span>
</span>
<span v-else class="file-hint">Select a .mjpeg file to play back</span>
</div> </div>
<!-- Player canvas --> <!-- Player -->
<div v-if="playbackState !== 'idle'" class="player-container"> <div v-if="videoUrl" class="player-container">
<canvas ref="canvasRef" class="playback-canvas"></canvas> <video
ref="videoRef"
:src="videoUrl"
class="playback-video"
@timeupdate="onTimeUpdate"
@ended="onEnded"
@play="playing = true"
@pause="playing = false"
></video>
<div class="playback-controls"> <div class="playback-controls">
<button <button
v-if="playbackState === 'playing'" v-if="!playing"
class="btn btn-sm btn-pause" class="btn btn-sm btn-play"
@click="pausePlayback" @click="play"
> >
⏸ Pause ▶ Play
</button> </button>
<button <button
v-if="playbackState === 'paused'" v-else
class="btn btn-sm btn-play" class="btn btn-sm btn-pause"
@click="resumePlayback" @click="pause"
> >
▶ Resume ⏸ Pause
</button> </button>
<button class="btn btn-sm btn-stop-play" @click="stopPlayback"> <button class="btn btn-sm btn-stop-play" @click="stopPlayback">
⏹ Stop ⏹ Stop
</button> </button>
<span class="playback-time">{{ playbackDuration }}</span> <span class="playback-time">{{ playbackTime }}</span>
</div> </div>
<div class="progress-bar"> <div class="progress-bar">
@@ -234,7 +144,6 @@ onUnmounted(() => {
color: #ddd; color: #ddd;
} }
/* Load area */
.load-area { .load-area {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -257,19 +166,17 @@ onUnmounted(() => {
color: #666; color: #666;
} }
/* Player */
.player-container { .player-container {
margin-bottom: 20px;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background: #1a1a2e; background: #1a1a2e;
} }
.playback-canvas { .playback-video {
width: 100%; width: 100%;
display: block; display: block;
max-height: 480px; max-height: 480px;
object-fit: contain; background: #000;
} }
.playback-controls { .playback-controls {
@@ -297,7 +204,6 @@ onUnmounted(() => {
transition: width 0.1s linear; transition: width 0.1s linear;
} }
/* Buttons */
.btn { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+90 -23
View File
@@ -1,5 +1,5 @@
import { ref, type Ref } from 'vue' import { ref, type Ref } from 'vue'
import { packFrames, downloadBlob } from './useStorage' import { downloadBlob } from './useStorage'
export type RecorderState = 'idle' | 'recording' export type RecorderState = 'idle' | 'recording'
@@ -20,7 +20,6 @@ async function* parseMjpegByMarkers(
response: Response, response: Response,
): AsyncGenerator<{ blob: Blob; timestamp: number }> { ): AsyncGenerator<{ blob: Blob; timestamp: number }> {
if (!response.body) throw new Error('Response body is null') if (!response.body) throw new Error('Response body is null')
const reader = response.body.getReader() const reader = response.body.getReader()
let buffer = new Uint8Array(0) let buffer = new Uint8Array(0)
const startTime = Date.now() const startTime = Date.now()
@@ -29,7 +28,6 @@ async function* parseMjpegByMarkers(
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
const newBuf = new Uint8Array(buffer.length + value.length) const newBuf = new Uint8Array(buffer.length + value.length)
newBuf.set(buffer) newBuf.set(buffer)
newBuf.set(value, buffer.length) newBuf.set(value, buffer.length)
@@ -40,7 +38,6 @@ async function* parseMjpegByMarkers(
if (soi === -1) break if (soi === -1) break
const eoi = indexOf(buffer, EOI, soi + 2) const eoi = indexOf(buffer, EOI, soi + 2)
if (eoi === -1) break if (eoi === -1) break
const jpegData = buffer.subarray(soi, eoi + 2) const jpegData = buffer.subarray(soi, eoi + 2)
if (jpegData.length > 100) { if (jpegData.length > 100) {
yield { blob: new Blob([jpegData], { type: 'image/jpeg' }), timestamp: Date.now() - startTime } yield { blob: new Blob([jpegData], { type: 'image/jpeg' }), timestamp: Date.now() - startTime }
@@ -53,18 +50,45 @@ async function* parseMjpegByMarkers(
} }
} }
/** Pick the best available video mime type — prefer MP4, fall back to WebM */
function bestMimeType(): string {
const types = [
'video/mp4;codecs=avc1.42E01E',
'video/mp4',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
]
for (const t of types) {
if (MediaRecorder.isTypeSupported(t)) return t
}
return 'video/webm'
}
function fileExtension(mimeType: string): string {
if (mimeType.startsWith('video/mp4')) return 'mp4'
return 'webm'
}
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: { 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 abortController: AbortController | null = null
let running = false let running = false
// MediaRecorder + canvas
let canvas: HTMLCanvasElement | null = null
let ctx: CanvasRenderingContext2D | null = null
let mediaRecorder: MediaRecorder | null = null
let chunks: Blob[] = []
let mimeType = ''
let ext = 'webm'
function reset() { function reset() {
running = false running = false
if (abortController) { if (abortController) {
@@ -75,7 +99,13 @@ export function useRecorder() {
clearInterval(timerInterval) clearInterval(timerInterval)
timerInterval = null timerInterval = null
} }
frames = [] if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
canvas = null
ctx = null
mediaRecorder = null
chunks = []
frameCount.value = 0 frameCount.value = 0
elapsedMs.value = 0 elapsedMs.value = 0
recorderState.value = 'idle' recorderState.value = 'idle'
@@ -84,6 +114,20 @@ export function useRecorder() {
async function startRecording(streamUrl: string) { async function startRecording(streamUrl: string) {
reset() reset()
// Set up canvas + MediaRecorder
canvas = document.createElement('canvas')
ctx = canvas.getContext('2d')!
const stream = canvas.captureStream(30)
mimeType = bestMimeType()
ext = fileExtension(mimeType)
chunks = []
mediaRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 2_000_000 })
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data)
}
mediaRecorder.start()
startTime = Date.now() startTime = Date.now()
recorderState.value = 'recording' recorderState.value = 'recording'
recorderError.value = null recorderError.value = null
@@ -95,6 +139,7 @@ export function useRecorder() {
abortController = new AbortController() abortController = new AbortController()
// Start fetching + drawing frames
try { try {
const response = await fetch(streamUrl, { const response = await fetch(streamUrl, {
signal: abortController.signal, signal: abortController.signal,
@@ -107,8 +152,25 @@ export function useRecorder() {
for await (const frame of parseMjpegByMarkers(response)) { for await (const frame of parseMjpegByMarkers(response)) {
if (!running) break if (!running) break
frames.push({ timestamp: frame.timestamp, blob: frame.blob })
frameCount.value = frames.length // Draw frame to canvas so MediaRecorder encodes it
try {
const bitmap = await createImageBitmap(frame.blob)
if (!canvas || !ctx) {
bitmap.close()
break
}
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
canvas.width = bitmap.width
canvas.height = bitmap.height
}
ctx.drawImage(bitmap, 0, 0)
bitmap.close()
} catch {
// corrupted frame — skip
}
frameCount.value++
} }
} catch (e: any) { } catch (e: any) {
if (e.name === 'AbortError') return if (e.name === 'AbortError') return
@@ -116,10 +178,10 @@ export function useRecorder() {
} }
} }
function stopRecording(): boolean { function stopRecording() {
if (recorderState.value !== 'recording') { if (recorderState.value !== 'recording') {
reset() reset()
return false return
} }
running = false running = false
@@ -134,23 +196,28 @@ export function useRecorder() {
} }
elapsedMs.value = Date.now() - startTime elapsedMs.value = Date.now() - startTime
const savedFrames = [...frames] if (!mediaRecorder || mediaRecorder.state === 'inactive') {
frames = [] recorderError.value = 'No video data recorded'
if (savedFrames.length === 0) {
recorderError.value = 'No frames captured — check that the stream is accessible'
recorderState.value = 'idle' recorderState.value = 'idle'
return false return
} }
// Pack and download if (frameCount.value === 0) {
const blob = packFrames(savedFrames) mediaRecorder.stop()
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) recorderError.value = 'No frames captured'
downloadBlob(blob, `recording-${ts}.mjpeg`) recorderState.value = 'idle'
return
}
recorderError.value = null // Request final blob and download
recorderState.value = 'idle' mediaRecorder.onstop = () => {
return true const blob = new Blob(chunks, { type: mimeType })
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
downloadBlob(blob, `recording-${ts}.${ext}`)
recorderError.value = null
recorderState.value = 'idle'
}
mediaRecorder.stop()
} }
function cancelRecording() { function cancelRecording() {
-57
View File
@@ -1,60 +1,3 @@
/**
* MJPEG frame packing / unpacking for download & playback.
*
* Binary format:
* [4B BE uint32 frameCount]
* Per frame: [4B BE uint32 timestamp(ms)] [4B BE uint32 jpegLen] [JPEG bytes]
*/
export interface Frame {
timestamp: number
blob: Blob
}
export interface RecordingFile {
name: string
duration: number
frameCount: number
data: Blob
}
export function packFrames(frames: { timestamp: number; blob: Blob }[]): Blob {
const headerSize = 4 + frames.length * 8
const header = new ArrayBuffer(headerSize)
const view = new DataView(header)
view.setUint32(0, frames.length)
let offset = 4
const parts: BlobPart[] = [header]
for (const f of frames) {
view.setUint32(offset, f.timestamp)
view.setUint32(offset + 4, f.blob.size)
offset += 8
parts.push(f.blob)
}
return new Blob(parts, { type: 'application/octet-stream' })
}
export async function unpackFrames(data: Blob): Promise<Frame[]> {
const buf = await data.arrayBuffer()
const view = new DataView(buf)
const frameCount = view.getUint32(0)
const frames: Frame[] = []
let offset = 4
for (let i = 0; i < frameCount; i++) {
const timestamp = view.getUint32(offset)
const jpegLen = view.getUint32(offset + 4)
offset += 8
frames.push({
timestamp,
blob: new Blob([buf.slice(offset, offset + jpegLen)], { type: 'image/jpeg' }),
})
offset += jpegLen
}
return frames
}
/** Trigger browser download of a blob as a file */ /** Trigger browser download of a blob as a file */
export function downloadBlob(blob: Blob, filename: string): void { export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)