diff --git a/ti2026h-web/src/App.vue b/ti2026h-web/src/App.vue index f16a913..490f4ff 100644 --- a/ti2026h-web/src/App.vue +++ b/ti2026h-web/src/App.vue @@ -1,6 +1,5 @@ @@ -94,12 +91,13 @@ onUnmounted(() => {
- + defineProps<{ - frameUrl: string | null + streamUrl: string | null isLive: boolean error: string | null }>() @@ -9,17 +9,18 @@ defineProps<{ @@ -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; +} diff --git a/ti2026h-web/src/composables/useMjpegStream.ts b/ti2026h-web/src/composables/useMjpegStream.ts index 1a59208..7bfa37f 100644 --- a/ti2026h-web/src/composables/useMjpegStream.ts +++ b/ti2026h-web/src/composables/useMjpegStream.ts @@ -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 = ref(null) const isLive = ref(false) const error: Ref = ref(null) - /** Latest frame Blob URL — bind to for live display */ - const frameUrl: Ref = 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, - boundary: string, - ): AsyncGenerator { - 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 } } diff --git a/ti2026h-web/src/composables/useRecorder.ts b/ti2026h-web/src/composables/useRecorder.ts index c926b09..3e2ebeb 100644 --- a/ti2026h-web/src/composables/useRecorder.ts +++ b/ti2026h-web/src/composables/useRecorder.ts @@ -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 = ref('idle') const elapsedMs = ref(0) const frameCount = ref(0) + const recorderError: Ref = ref(null) let frames: Frame[] = [] let startTime = 0 let timerInterval: ReturnType | 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 { 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,