demo live stream
This commit is contained in:
154
web/src/lib/live-mse.ts
Normal file
154
web/src/lib/live-mse.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
export const LIVE_FRAME_INIT = 0x01;
|
||||
export const LIVE_FRAME_MEDIA = 0x02;
|
||||
export const LIVE_FRAME_ERROR = 0x03;
|
||||
|
||||
export type LiveFrame = {
|
||||
type: number;
|
||||
payload: Uint8Array;
|
||||
};
|
||||
|
||||
export function decodeLiveFrame(data: ArrayBuffer): LiveFrame | null {
|
||||
const bytes = new Uint8Array(data);
|
||||
if (bytes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: bytes[0],
|
||||
payload: bytes.slice(1)
|
||||
};
|
||||
}
|
||||
|
||||
export class SourceBufferAppender {
|
||||
private readonly sourceBuffer: SourceBuffer;
|
||||
private readonly onError: (err: unknown) => void;
|
||||
private readonly queue: Uint8Array[] = [];
|
||||
private disposed = false;
|
||||
|
||||
constructor(sourceBuffer: SourceBuffer, onError: (err: unknown) => void) {
|
||||
this.sourceBuffer = sourceBuffer;
|
||||
this.onError = onError;
|
||||
this.onUpdateEnd = this.onUpdateEnd.bind(this);
|
||||
this.sourceBuffer.addEventListener("updateend", this.onUpdateEnd);
|
||||
}
|
||||
|
||||
append(segment: Uint8Array) {
|
||||
if (this.disposed || segment.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.queue.push(segment);
|
||||
this.drain();
|
||||
}
|
||||
|
||||
pendingSegments(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
this.queue.length = 0;
|
||||
this.sourceBuffer.removeEventListener("updateend", this.onUpdateEnd);
|
||||
}
|
||||
|
||||
private onUpdateEnd() {
|
||||
this.drain();
|
||||
}
|
||||
|
||||
private drain() {
|
||||
if (this.disposed || this.sourceBuffer.updating || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
const next = this.queue.shift();
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = next.buffer.slice(next.byteOffset, next.byteOffset + next.byteLength) as ArrayBuffer;
|
||||
this.sourceBuffer.appendBuffer(raw);
|
||||
} catch (err) {
|
||||
this.onError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pickLiveMimeType(initSegment: Uint8Array): string | null {
|
||||
const hasAvc1 = includesASCII(initSegment, "avc1");
|
||||
const hasHvc1 = includesASCII(initSegment, "hvc1");
|
||||
const hasHev1 = includesASCII(initSegment, "hev1");
|
||||
const avcCodec = parseAvcCodec(initSegment);
|
||||
|
||||
const candidates: string[] = [];
|
||||
if (hasAvc1 && avcCodec) {
|
||||
candidates.push(`video/mp4; codecs="${avcCodec},mp4a.40.2"`);
|
||||
candidates.push(`video/mp4; codecs="${avcCodec}"`);
|
||||
}
|
||||
if (hasAvc1) {
|
||||
candidates.push('video/mp4; codecs="avc1.640028,mp4a.40.2"');
|
||||
candidates.push('video/mp4; codecs="avc1.42E01E,mp4a.40.2"');
|
||||
}
|
||||
if (hasHvc1) {
|
||||
candidates.push('video/mp4; codecs="hvc1,mp4a.40.2"');
|
||||
candidates.push('video/mp4; codecs="hvc1"');
|
||||
}
|
||||
if (hasHev1) {
|
||||
candidates.push('video/mp4; codecs="hev1,mp4a.40.2"');
|
||||
candidates.push('video/mp4; codecs="hev1"');
|
||||
}
|
||||
candidates.push("video/mp4");
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (MediaSource.isTypeSupported(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildLiveWsUrl(streamId: string): string {
|
||||
const current = new URL(window.location.href);
|
||||
const apiBaseParam = current.searchParams.get("apiBase");
|
||||
const apiBase = apiBaseParam ? new URL(apiBaseParam, current) : current;
|
||||
const protocol = apiBase.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${apiBase.host}/api/cctv/live/ws/${encodeURIComponent(streamId)}`;
|
||||
}
|
||||
|
||||
function parseAvcCodec(data: Uint8Array): string | null {
|
||||
const idx = indexOfASCII(data, "avcC");
|
||||
if (idx < 0 || idx+8 >= data.length) {
|
||||
return null;
|
||||
}
|
||||
const profile = data[idx + 5];
|
||||
const compatibility = data[idx + 6];
|
||||
const level = data[idx + 7];
|
||||
return `avc1.${hex2(profile)}${hex2(compatibility)}${hex2(level)}`;
|
||||
}
|
||||
|
||||
function includesASCII(data: Uint8Array, text: string): boolean {
|
||||
return indexOfASCII(data, text) >= 0;
|
||||
}
|
||||
|
||||
function indexOfASCII(data: Uint8Array, text: string): number {
|
||||
if (text.length === 0 || text.length > data.length) {
|
||||
return -1;
|
||||
}
|
||||
const codes = Array.from(text, (char) => char.charCodeAt(0));
|
||||
for (let i = 0; i <= data.length - codes.length; i++) {
|
||||
let matched = true;
|
||||
for (let j = 0; j < codes.length; j++) {
|
||||
if (data[i + j] !== codes[j]) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function hex2(value: number): string {
|
||||
return value.toString(16).toUpperCase().padStart(2, "0");
|
||||
}
|
||||
392
web/src/routes/cam-poc/+page.svelte
Normal file
392
web/src/routes/cam-poc/+page.svelte
Normal file
@@ -0,0 +1,392 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import {
|
||||
LIVE_FRAME_ERROR,
|
||||
LIVE_FRAME_INIT,
|
||||
LIVE_FRAME_MEDIA,
|
||||
SourceBufferAppender,
|
||||
buildLiveWsUrl,
|
||||
decodeLiveFrame,
|
||||
pickLiveMimeType
|
||||
} from "$lib/live-mse";
|
||||
|
||||
const DEFAULT_STREAM_ID = "w9oheo19vhjmqgs";
|
||||
type Status = "idle" | "connecting" | "streaming" | "closed" | "error";
|
||||
|
||||
let streamId = $state(DEFAULT_STREAM_ID);
|
||||
let wsUrl = $state("");
|
||||
let status = $state<Status>("idle");
|
||||
let wsState = $state("disconnected");
|
||||
let sourceMime = $state("pending");
|
||||
let bufferedRanges = $state("n/a");
|
||||
let errorText = $state("");
|
||||
let playbackHint = $state("");
|
||||
let initFrames = $state(0);
|
||||
let mediaFrames = $state(0);
|
||||
let pendingSegments = $state(0);
|
||||
|
||||
let videoEl: HTMLVideoElement | null = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let mediaSource: MediaSource | null = null;
|
||||
let objectUrl: string | null = null;
|
||||
let appender: SourceBufferAppender | null = null;
|
||||
let pendingInit: Uint8Array | null = null;
|
||||
let earlyMedia: Uint8Array[] = [];
|
||||
let monitorTimer: number | null = null;
|
||||
let didInitialSeek = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
wsUrl = buildLiveWsUrl(streamId);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const current = new URL(window.location.href);
|
||||
const streamParam = current.searchParams.get("streamId");
|
||||
if (streamParam && streamParam.length > 0) {
|
||||
streamId = streamParam;
|
||||
}
|
||||
start();
|
||||
monitorTimer = window.setInterval(() => {
|
||||
bufferedRanges = formatBufferedRanges(videoEl);
|
||||
ensurePlaybackPosition();
|
||||
}, 500);
|
||||
return () => {
|
||||
if (monitorTimer !== null) {
|
||||
window.clearInterval(monitorTimer);
|
||||
monitorTimer = null;
|
||||
}
|
||||
stop("idle");
|
||||
};
|
||||
});
|
||||
|
||||
function start() {
|
||||
if (streamId.trim().length === 0) {
|
||||
errorText = "Stream ID is required";
|
||||
status = "error";
|
||||
return;
|
||||
}
|
||||
stop("idle");
|
||||
status = "connecting";
|
||||
wsState = "connecting";
|
||||
errorText = "";
|
||||
playbackHint = "";
|
||||
sourceMime = "pending";
|
||||
initFrames = 0;
|
||||
mediaFrames = 0;
|
||||
pendingSegments = 0;
|
||||
didInitialSeek = false;
|
||||
earlyMedia = [];
|
||||
pendingInit = null;
|
||||
|
||||
mediaSource = new MediaSource();
|
||||
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
|
||||
objectUrl = URL.createObjectURL(mediaSource);
|
||||
if (videoEl) {
|
||||
videoEl.src = objectUrl;
|
||||
}
|
||||
|
||||
ws = new WebSocket(buildLiveWsUrl(streamId));
|
||||
ws.binaryType = "arraybuffer";
|
||||
ws.onopen = () => {
|
||||
wsState = "open";
|
||||
status = "streaming";
|
||||
void tryStartPlayback();
|
||||
};
|
||||
ws.onerror = () => {
|
||||
errorText = "WebSocket error";
|
||||
wsState = "error";
|
||||
status = "error";
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (status !== "error") {
|
||||
status = "closed";
|
||||
}
|
||||
wsState = "closed";
|
||||
};
|
||||
ws.onmessage = async (event) => {
|
||||
const payload = await toArrayBuffer(event.data);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
const frame = decodeLiveFrame(payload);
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
handleFrame(frame.type, frame.payload);
|
||||
};
|
||||
}
|
||||
|
||||
function stop(nextStatus: Status) {
|
||||
if (ws) {
|
||||
ws.onopen = null;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.onmessage = null;
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
ws = null;
|
||||
}
|
||||
if (appender) {
|
||||
appender.dispose();
|
||||
appender = null;
|
||||
}
|
||||
pendingInit = null;
|
||||
earlyMedia = [];
|
||||
pendingSegments = 0;
|
||||
didInitialSeek = false;
|
||||
if (mediaSource?.readyState === "open") {
|
||||
try {
|
||||
mediaSource.endOfStream();
|
||||
} catch {
|
||||
// ignore end-of-stream errors
|
||||
}
|
||||
}
|
||||
mediaSource = null;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
}
|
||||
if (videoEl) {
|
||||
videoEl.pause();
|
||||
videoEl.muted = false;
|
||||
videoEl.currentTime = 0;
|
||||
videoEl.removeAttribute("src");
|
||||
videoEl.load();
|
||||
}
|
||||
status = nextStatus;
|
||||
wsState = nextStatus === "idle" ? "disconnected" : wsState;
|
||||
}
|
||||
|
||||
function onSourceOpen() {
|
||||
if (pendingInit) {
|
||||
initSourceBuffer(pendingInit);
|
||||
pendingInit = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFrame(frameType: number, payload: Uint8Array) {
|
||||
if (frameType === LIVE_FRAME_ERROR) {
|
||||
errorText = new TextDecoder().decode(payload) || "Live stream error";
|
||||
status = "error";
|
||||
return;
|
||||
}
|
||||
if (frameType === LIVE_FRAME_INIT) {
|
||||
initFrames += 1;
|
||||
if (!mediaSource || mediaSource.readyState !== "open") {
|
||||
pendingInit = payload;
|
||||
return;
|
||||
}
|
||||
initSourceBuffer(payload);
|
||||
return;
|
||||
}
|
||||
if (frameType === LIVE_FRAME_MEDIA) {
|
||||
mediaFrames += 1;
|
||||
if (appender) {
|
||||
appender.append(payload);
|
||||
pendingSegments = appender.pendingSegments();
|
||||
if (mediaFrames <= 3) {
|
||||
void tryStartPlayback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
earlyMedia.push(payload);
|
||||
pendingSegments = earlyMedia.length;
|
||||
}
|
||||
}
|
||||
|
||||
function initSourceBuffer(initSegment: Uint8Array) {
|
||||
if (!mediaSource || mediaSource.readyState !== "open") {
|
||||
pendingInit = initSegment;
|
||||
return;
|
||||
}
|
||||
if (appender) {
|
||||
return;
|
||||
}
|
||||
const mime = pickLiveMimeType(initSegment);
|
||||
if (!mime) {
|
||||
errorText = "No supported MP4 MIME type found for this stream";
|
||||
status = "error";
|
||||
return;
|
||||
}
|
||||
sourceMime = mime;
|
||||
let sourceBuffer: SourceBuffer;
|
||||
try {
|
||||
sourceBuffer = mediaSource.addSourceBuffer(mime);
|
||||
} catch (err) {
|
||||
errorText = `Failed to create SourceBuffer (${String(err)})`;
|
||||
status = "error";
|
||||
return;
|
||||
}
|
||||
appender = new SourceBufferAppender(sourceBuffer, (err) => {
|
||||
errorText = `appendBuffer failed: ${String(err)}`;
|
||||
status = "error";
|
||||
});
|
||||
appender.append(initSegment);
|
||||
for (const segment of earlyMedia) {
|
||||
appender.append(segment);
|
||||
}
|
||||
earlyMedia = [];
|
||||
pendingSegments = appender.pendingSegments();
|
||||
void tryStartPlayback();
|
||||
}
|
||||
|
||||
async function tryStartPlayback() {
|
||||
if (!videoEl || status === "error" || !videoEl.paused) {
|
||||
return;
|
||||
}
|
||||
playbackHint = "";
|
||||
try {
|
||||
videoEl.muted = false;
|
||||
await videoEl.play();
|
||||
return;
|
||||
} catch {
|
||||
// Some browsers block autoplay with audio; muted autoplay is usually allowed.
|
||||
}
|
||||
|
||||
try {
|
||||
videoEl.muted = true;
|
||||
await videoEl.play();
|
||||
playbackHint = "Playback auto-started muted by browser policy. Click Unmute for audio.";
|
||||
ensurePlaybackPosition();
|
||||
} catch {
|
||||
playbackHint = "Playback is waiting for user interaction. Click the video Play button.";
|
||||
}
|
||||
}
|
||||
|
||||
async function unmutePlayback() {
|
||||
if (!videoEl) {
|
||||
return;
|
||||
}
|
||||
videoEl.muted = false;
|
||||
try {
|
||||
await videoEl.play();
|
||||
playbackHint = "";
|
||||
} catch {
|
||||
playbackHint = "Browser blocked unmute without interaction. Use the video controls to continue.";
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePlaybackPosition() {
|
||||
if (!videoEl || videoEl.buffered.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstStart = videoEl.buffered.start(0);
|
||||
const lastEnd = videoEl.buffered.end(videoEl.buffered.length - 1);
|
||||
const liveTarget = Math.max(firstStart, lastEnd - 0.35);
|
||||
const current = videoEl.currentTime;
|
||||
|
||||
try {
|
||||
if (!didInitialSeek) {
|
||||
videoEl.currentTime = liveTarget;
|
||||
didInitialSeek = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (current < firstStart-0.2 || current > lastEnd+0.2) {
|
||||
videoEl.currentTime = liveTarget;
|
||||
}
|
||||
} catch {
|
||||
// Ignore seek failures while metadata is still stabilizing.
|
||||
}
|
||||
}
|
||||
|
||||
function formatBufferedRanges(video: HTMLVideoElement | null): string {
|
||||
if (!video || video.buffered.length === 0) {
|
||||
return "empty";
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < video.buffered.length; i++) {
|
||||
const start = video.buffered.start(i).toFixed(2);
|
||||
const end = video.buffered.end(i).toFixed(2);
|
||||
chunks.push(`${start}-${end}s`);
|
||||
}
|
||||
return chunks.join(" | ");
|
||||
}
|
||||
|
||||
async function toArrayBuffer(data: unknown): Promise<ArrayBuffer | null> {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return data;
|
||||
}
|
||||
if (data instanceof Blob) {
|
||||
return await data.arrayBuffer();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-linear-to-br from-stone-950 via-slate-900 to-zinc-800 px-4 py-6 text-zinc-100 md:px-8">
|
||||
<div class="mx-auto grid w-full max-w-6xl gap-4">
|
||||
<div class="rounded-2xl border border-zinc-100/15 bg-black/20 p-5 backdrop-blur">
|
||||
<h1 class="text-2xl font-semibold tracking-tight md:text-3xl">Live Camera PoC</h1>
|
||||
<p class="mt-1 text-sm text-zinc-300">
|
||||
WebSocket binary stream to MSE using muxed fMP4 init/media segments.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-[1fr_auto_auto]">
|
||||
<Input
|
||||
placeholder="Stream ID"
|
||||
bind:value={streamId}
|
||||
disabled={status === "connecting"}
|
||||
/>
|
||||
<Button onclick={start} disabled={status === "connecting"}>Connect</Button>
|
||||
<Button variant="secondary" onclick={() => stop("idle")}>Stop</Button>
|
||||
</div>
|
||||
<p class="mt-3 break-all text-xs text-zinc-400">{wsUrl}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-100/15 bg-black/30">
|
||||
<video bind:this={videoEl} class="h-full min-h-85 w-full bg-black" controls autoplay playsinline></video>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-zinc-100/15 bg-black/20 p-4 text-sm backdrop-blur">
|
||||
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||
<span>Status</span>
|
||||
<span class="font-medium">{status}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||
<span>WebSocket</span>
|
||||
<span class="font-medium">{wsState}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||
<span>SourceBuffer MIME</span>
|
||||
<span class="font-medium">{sourceMime}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||
<span>Init frames</span>
|
||||
<span class="font-medium">{initFrames}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||
<span>Media frames</span>
|
||||
<span class="font-medium">{mediaFrames}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-b border-zinc-100/10 py-2">
|
||||
<span>Pending append queue</span>
|
||||
<span class="font-medium">{pendingSegments}</span>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div>Buffered ranges</div>
|
||||
<div class="mt-1 break-all text-xs text-zinc-300">{bufferedRanges}</div>
|
||||
</div>
|
||||
{#if errorText.length > 0}
|
||||
<div class="mt-3 rounded-lg border border-rose-400/40 bg-rose-900/25 px-3 py-2 text-xs text-rose-100">
|
||||
{errorText}
|
||||
</div>
|
||||
{/if}
|
||||
{#if playbackHint.length > 0}
|
||||
<div class="mt-3 rounded-lg border border-amber-300/40 bg-amber-900/20 px-3 py-2 text-xs text-amber-100">
|
||||
<p>{playbackHint}</p>
|
||||
<Button class="mt-2" size="sm" variant="secondary" onclick={unmutePlayback}>Unmute</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user