refactor: flatten project structure, remove ti2026h-web subdirectory

This commit is contained in:
2026-08-01 16:08:44 +08:00
parent 6bbe83e1f1
commit 386d8d5852
26 changed files with 18 additions and 1 deletions
+22
View File
@@ -0,0 +1,22 @@
import { ref, type Ref } from 'vue'
export function useMjpegStream() {
const streamUrl: Ref<string | null> = ref(null)
const isLive = ref(false)
const error: Ref<string | null> = ref(null)
function start(url: string) {
stop()
streamUrl.value = url
isLive.value = true
error.value = null
}
function stop() {
streamUrl.value = null
isLive.value = false
error.value = null
}
return { streamUrl, isLive, error, start, stop }
}
+253
View File
@@ -0,0 +1,253 @@
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
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
}
elapsedMs.value = Date.now() - startTime
if (frames.length === 0) {
recorderState.value = 'idle'
return null
}
const recording: Recording = {
id: crypto.randomUUID(),
name: `Recording ${new Date().toLocaleString()}`,
createdAt: new Date(),
duration: elapsedMs.value,
frameCount: frames.length,
frames: [...frames],
}
try {
await saveRecording(recording)
} finally {
recorderState.value = 'idle'
}
frames = []
return recording
}
function cancelRecording() {
if (abortController) {
abortController.abort()
abortController = null
}
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
frames = []
frameCount.value = 0
elapsedMs.value = 0
recorderError.value = null
recorderState.value = 'idle'
}
function formatElapsed(ms: number): string {
const totalSec = Math.floor(ms / 1000)
const min = Math.floor(totalSec / 60)
const sec = totalSec % 60
const centis = Math.floor((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,
}
}
+111
View File
@@ -0,0 +1,111 @@
/**
* IndexedDB wrapper for storing MJPEG recordings.
* Each recording holds an array of frames (JPEG blobs) with timestamps.
*/
export interface Frame {
/** Milliseconds since recording start */
timestamp: number
blob: Blob
}
export interface Recording {
id: string
name: string
createdAt: Date
duration: number // ms
frameCount: number
frames: Frame[]
}
const DB_NAME = 'mjpeg-viewer'
const DB_VERSION = 1
const STORE_NAME = 'recordings'
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
export async function saveRecording(recording: Recording): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
// Serialize: convert Date to ISO string and store frames
const record = {
...recording,
createdAt: recording.createdAt.toISOString(),
}
store.put(record)
tx.oncomplete = () => {
db.close()
resolve()
}
tx.onerror = () => reject(tx.error)
})
}
export async function getAllRecordings(): Promise<Recording[]> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.getAll()
request.onsuccess = () => {
db.close()
const results = (request.result || []).map((r: any) => ({
...r,
createdAt: new Date(r.createdAt),
}))
// Sort newest first
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
resolve(results)
}
request.onerror = () => reject(request.error)
})
}
export async function deleteRecording(id: string): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
store.delete(id)
tx.oncomplete = () => {
db.close()
resolve()
}
tx.onerror = () => reject(tx.error)
})
}
export async function getRecording(id: string): Promise<Recording | undefined> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.get(id)
request.onsuccess = () => {
db.close()
if (!request.result) {
resolve(undefined)
return
}
resolve({
...request.result,
createdAt: new Date(request.result.createdAt),
})
}
request.onerror = () => reject(request.error)
})
}