Files
ti2026h/src/composables/useRecorder.ts
T
SkyLiCoder a7697fd4a1 fix: pack all frames into single blob for IndexedDB storage
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.
2026-08-01 17:03:47 +08:00

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,
}
}