a7697fd4a1
Previously each frame was stored as a separate Blob in the Recording object, causing IndexedDB to hang when writing 100+ blobs in one transaction. Now all frames are packed into a single binary blob: [4B frameCount][per frame: 4B ts, 4B len, JPEG data] DB version bumped to 2 (old store deleted on upgrade). PlaybackPanel unpacks frames on demand with a per-recording cache.
204 lines
5.4 KiB
TypeScript
204 lines
5.4 KiB
TypeScript
import { ref, type Ref } from 'vue'
|
|
import { saveRecording, packFrames, type Recording } from './useStorage'
|
|
|
|
export type RecorderState = 'idle' | 'recording' | 'saving'
|
|
|
|
const SOI = new Uint8Array([0xff, 0xd8])
|
|
const EOI = new Uint8Array([0xff, 0xd9])
|
|
|
|
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
|
|
}
|
|
|
|
async function* parseMjpegByMarkers(
|
|
response: Response,
|
|
): AsyncGenerator<{ blob: Blob; timestamp: number }> {
|
|
if (!response.body) throw new Error('Response body is null')
|
|
|
|
const reader = response.body.getReader()
|
|
let buffer = new Uint8Array(0)
|
|
const startTime = Date.now()
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
const newBuf = new Uint8Array(buffer.length + value.length)
|
|
newBuf.set(buffer)
|
|
newBuf.set(value, buffer.length)
|
|
buffer = newBuf
|
|
|
|
while (true) {
|
|
const soi = indexOf(buffer, SOI)
|
|
if (soi === -1) break
|
|
const eoi = indexOf(buffer, EOI, soi + 2)
|
|
if (eoi === -1) break
|
|
|
|
const jpegData = buffer.subarray(soi, eoi + 2)
|
|
if (jpegData.length > 100) {
|
|
yield { blob: new Blob([jpegData], { type: 'image/jpeg' }), timestamp: Date.now() - startTime }
|
|
}
|
|
buffer = buffer.subarray(eoi + 2)
|
|
}
|
|
}
|
|
} 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: { timestamp: number; blob: Blob }[] = []
|
|
let startTime = 0
|
|
let timerInterval: ReturnType<typeof setInterval> | null = null
|
|
let abortController: AbortController | null = null
|
|
let running = false
|
|
|
|
function reset() {
|
|
running = false
|
|
if (abortController) {
|
|
abortController.abort()
|
|
abortController = null
|
|
}
|
|
if (timerInterval) {
|
|
clearInterval(timerInterval)
|
|
timerInterval = null
|
|
}
|
|
frames = []
|
|
frameCount.value = 0
|
|
elapsedMs.value = 0
|
|
recorderState.value = 'idle'
|
|
}
|
|
|
|
async function startRecording(streamUrl: string) {
|
|
reset()
|
|
|
|
startTime = Date.now()
|
|
recorderState.value = 'recording'
|
|
recorderError.value = null
|
|
running = true
|
|
|
|
timerInterval = setInterval(() => {
|
|
if (running) elapsedMs.value = Date.now() - startTime
|
|
}, 100)
|
|
|
|
abortController = new AbortController()
|
|
|
|
try {
|
|
const response = await fetch(streamUrl, {
|
|
signal: abortController.signal,
|
|
cache: 'no-store',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
}
|
|
|
|
for await (const frame of parseMjpegByMarkers(response)) {
|
|
if (!running) 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 reset state here — user may still want to stop and see the error
|
|
}
|
|
}
|
|
|
|
async function stopRecording(): Promise<Recording | null> {
|
|
if (recorderState.value !== 'recording') {
|
|
if (recorderState.value === 'saving') return null
|
|
reset()
|
|
return null
|
|
}
|
|
|
|
recorderState.value = 'saving'
|
|
running = false
|
|
|
|
if (abortController) {
|
|
abortController.abort()
|
|
abortController = null
|
|
}
|
|
if (timerInterval) {
|
|
clearInterval(timerInterval)
|
|
timerInterval = null
|
|
}
|
|
elapsedMs.value = Date.now() - startTime
|
|
|
|
const savedFrames = [...frames]
|
|
frames = []
|
|
|
|
// Safety: force back to idle after 5s even if IndexedDB hangs
|
|
const safetyTimer = setTimeout(() => {
|
|
if (recorderState.value === 'saving') {
|
|
recorderError.value = 'Save timed out'
|
|
recorderState.value = 'idle'
|
|
}
|
|
}, 5000)
|
|
|
|
if (savedFrames.length === 0) {
|
|
clearTimeout(safetyTimer)
|
|
recorderError.value = 'No frames captured — check that the stream is accessible'
|
|
recorderState.value = 'idle'
|
|
return null
|
|
}
|
|
|
|
const recording: Recording = {
|
|
id: crypto.randomUUID(),
|
|
name: `Recording ${new Date().toLocaleString()}`,
|
|
createdAt: new Date(),
|
|
duration: elapsedMs.value,
|
|
frameCount: savedFrames.length,
|
|
data: packFrames(savedFrames),
|
|
}
|
|
|
|
try {
|
|
await saveRecording(recording)
|
|
recorderError.value = null
|
|
return recording
|
|
} catch (e: any) {
|
|
recorderError.value = `Save failed: ${e.message || e}`
|
|
return null
|
|
} finally {
|
|
clearTimeout(safetyTimer)
|
|
recorderState.value = 'idle'
|
|
}
|
|
}
|
|
|
|
function cancelRecording() {
|
|
reset()
|
|
recorderError.value = null
|
|
}
|
|
|
|
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 {
|
|
recorderState,
|
|
elapsedMs,
|
|
frameCount,
|
|
recorderError,
|
|
startRecording,
|
|
stopRecording,
|
|
cancelRecording,
|
|
formatElapsed,
|
|
}
|
|
}
|