Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,19 @@ jobs:

dashboard:
runs-on: ubuntu-latest
defaults:
run:
working-directory: dashboard
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install deps
run: cd dashboard && npm install
run: npm install
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Build
run: cd dashboard && npm run build
run: npm run build
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,10 @@ The full demo stack — engine, control plane, dashboard, Redis, Prometheus, and
docker compose up -d --build
```

Then, to stream a video into the running stack — the file must live under `Doc/dev/`, since that's the directory mounted into the engine container as `/media/`:
Then, to stream a video into the running stack — the file must live under `Resource/media/`, since that's the directory mounted into the engine container as `/media/`:

```bash
bash scripts/start_stream.sh Doc/dev/your-video.mp4
bash scripts/start_stream.sh Resource/media/your-video.mp4
```

Open `http://localhost:5173`, enter `127.0.0.1` / `5000` in Session Control, and click **Connect** to watch. Starting a stream and viewing it are deliberately separate steps — Connect only opens the viewer, it does not start a stream.
Expand All @@ -172,7 +172,7 @@ All scripts are in `scripts/` and assume you're in the repo root.

```bash
docker compose up -d --build # start the full stack first
bash scripts/start_stream.sh [/path/to/video.mp4] # defaults to a file under Doc/dev/
bash scripts/start_stream.sh Resource/media/your-video.mp4
```

Open `http://localhost:5173`, connect to `127.0.0.1:5000`, then run the script — it waits for services, posts the stream request to the engine's admin API, and confirms packets are actually flowing before exiting (the stream keeps running in the engine independently of the script).
Expand Down
12 changes: 9 additions & 3 deletions dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,31 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix"
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"eslint": "^9.39.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.68.0",
"vite": "^5.4.0"
"vite": "^5.4.0",
"vitest": "^2.1.9"
}
}
70 changes: 70 additions & 0 deletions dashboard/src/components/MetricsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MetricsPanel } from "./MetricsPanel";
import type { StreamMetrics } from "../types";

const baseMetrics: StreamMetrics = {
framesReceived: 100,
packetsReceived: 200,
bytesReceived: 1024 * 1024,
currentBitrateMbps: 2.5,
jitterMs: 3,
lossPercent: 0,
rttMs: 20,
streamActive: true,
};

describe("MetricsPanel", () => {
it("shows Connected when connected, Disconnected otherwise", () => {
const { rerender } = render(<MetricsPanel metrics={baseMetrics} connected={true} />);
expect(screen.getByText("Connected")).toBeInTheDocument();

rerender(<MetricsPanel metrics={baseMetrics} connected={false} />);
expect(screen.getByText("Disconnected")).toBeInTheDocument();
});

it("shows the no-stream banner only when connected but no stream is active", () => {
render(<MetricsPanel metrics={{ ...baseMetrics, streamActive: false }} connected={true} />);
expect(screen.getByText(/No stream running/)).toBeInTheDocument();
});

it("hides the no-stream banner when a stream is active", () => {
render(<MetricsPanel metrics={{ ...baseMetrics, streamActive: true }} connected={true} />);
expect(screen.queryByText(/No stream running/)).not.toBeInTheDocument();
});

it("hides the no-stream banner when not connected at all", () => {
// connected=false means "idle", not "stream should be running but isn't"
render(<MetricsPanel metrics={{ ...baseMetrics, streamActive: false }} connected={false} />);
expect(screen.queryByText(/No stream running/)).not.toBeInTheDocument();
});

it("shows a network-degradation warning when loss or RTT is high", () => {
render(
<MetricsPanel metrics={{ ...baseMetrics, lossPercent: 10 }} connected={true} />,
);
expect(screen.getByText(/Network degradation detected/)).toBeInTheDocument();
});

it("suppresses the degradation warning while no stream is active", () => {
// Avoids flagging "degraded network" for numbers that are just placeholder zeros/stale.
render(
<MetricsPanel
metrics={{ ...baseMetrics, lossPercent: 10, streamActive: false }}
connected={true}
/>,
);
expect(screen.queryByText(/Network degradation detected/)).not.toBeInTheDocument();
});

it("renders counters formatted with locale separators and MB conversion", () => {
render(
<MetricsPanel
metrics={{ ...baseMetrics, framesReceived: 12345, bytesReceived: 2 * 1024 * 1024 }}
connected={true}
/>,
);
expect(screen.getByText("12,345")).toBeInTheDocument();
expect(screen.getByText("2.00 MB")).toBeInTheDocument();
});
});
20 changes: 2 additions & 18 deletions dashboard/src/components/ParticleBackground.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { useTheme } from "../contexts/ThemeContext";

interface Particle {
Expand All @@ -21,8 +21,6 @@ export function ParticleBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const mouseRef = useRef({ x: 0, y: 0 });
const particlesRef = useRef<Particle[]>([]);
const [fps, setFps] = useState(0);
const frameCountRef = useRef(0);
const lastTimeRef = useRef(Date.now());
const { theme } = useTheme();

Expand Down Expand Up @@ -112,14 +110,7 @@ export function ParticleBackground() {
const delta = now - lastTimeRef.current;

if (delta >= 16) {
frameCountRef.current++;
if (delta >= 1000) {
setFps(frameCountRef.current);
frameCountRef.current = 0;
lastTimeRef.current = now;
} else {
lastTimeRef.current += 16;
}
lastTimeRef.current = now;

// Background gradient based on theme
const isDark = theme === "dark";
Expand Down Expand Up @@ -186,12 +177,6 @@ export function ParticleBackground() {
}
});
});

// FPS counter
ctx.globalAlpha = 1;
ctx.fillStyle = isDark ? "rgba(107, 114, 128, 0.6)" : "rgba(107, 114, 128, 0.4)";
ctx.font = "12px monospace";
ctx.fillText(`FPS: ${fps}`, 12, 24);
}

animationFrameId = requestAnimationFrame(animate);
Expand All @@ -204,7 +189,6 @@ export function ParticleBackground() {
window.removeEventListener("resize", resizeCanvas);
window.removeEventListener("mousemove", handleMouseMove);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme]);

return (
Expand Down
160 changes: 160 additions & 0 deletions dashboard/src/hooks/useStream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { describe, it, expect } from "vitest";
import {
nalType,
stripStartCode,
toLengthPrefixed,
buildAvcC,
codecFromSps,
readPtsUs,
kTypeVideo,
kTypeAudio,
kFrameHeaderLen,
} from "./useStream";

describe("nalType", () => {
it("reads the NAL type after a 4-byte start code", () => {
// type 7 = SPS
const data = new Uint8Array([0x00, 0x00, 0x00, 0x01, 0x67, 0xaa, 0xbb]);
expect(nalType(data)).toBe(7);
});

it("reads the NAL type after a 3-byte start code", () => {
// type 8 = PPS
const data = new Uint8Array([0x00, 0x00, 0x01, 0x68, 0xaa]);
expect(nalType(data)).toBe(8);
});

it("masks off nal_ref_idc bits, keeping only the 5-bit type", () => {
// 0x65 = 0110 0101 -> nal_ref_idc=011, type=00101=5 (IDR slice)
const data = new Uint8Array([0x00, 0x00, 0x00, 0x01, 0x65]);
expect(nalType(data)).toBe(5);
});

it("returns null with no start code", () => {
expect(nalType(new Uint8Array([0x01, 0x02, 0x03, 0x04]))).toBeNull();
});

it("returns null when the start code is the entire buffer", () => {
expect(nalType(new Uint8Array([0x00, 0x00, 0x00, 0x01]))).toBeNull();
});
});

describe("stripStartCode", () => {
it("strips a 4-byte start code", () => {
const data = new Uint8Array([0x00, 0x00, 0x00, 0x01, 0x67, 0x42]);
expect(stripStartCode(data)).toEqual(new Uint8Array([0x67, 0x42]));
});

it("strips a 3-byte start code", () => {
const data = new Uint8Array([0x00, 0x00, 0x01, 0x68, 0x11]);
expect(stripStartCode(data)).toEqual(new Uint8Array([0x68, 0x11]));
});

it("returns the input unchanged when there is no start code", () => {
const data = new Uint8Array([0x67, 0x42, 0x00]);
expect(stripStartCode(data)).toEqual(data);
});
});

describe("toLengthPrefixed", () => {
it("prefixes the NAL with its 4-byte big-endian length", () => {
const nal = new Uint8Array([0x65, 0x01, 0x02, 0x03]);
const out = toLengthPrefixed(nal);
expect(out).toEqual(new Uint8Array([0x00, 0x00, 0x00, 0x04, 0x65, 0x01, 0x02, 0x03]));
});

it("handles an empty NAL", () => {
expect(toLengthPrefixed(new Uint8Array([]))).toEqual(new Uint8Array([0, 0, 0, 0]));
});

it("handles lengths spanning multiple length bytes", () => {
const nal = new Uint8Array(300).fill(0xab);
const out = toLengthPrefixed(nal);
// 300 = 0x012C
expect(out.slice(0, 4)).toEqual(new Uint8Array([0x00, 0x00, 0x01, 0x2c]));
expect(out.length).toBe(304);
});
});

describe("buildAvcC", () => {
// Regression test: buildAvcC once stripped the SPS/PPS NAL header byte,
// corrupting decoded width/height — avcC stores the full NAL (ISO 14496-15).
it("embeds the full SPS/PPS NAL unit, header byte included", () => {
const sps = new Uint8Array([0x67, 0x42, 0x00, 0x1e, 0xaa, 0xbb]);
const pps = new Uint8Array([0x68, 0xce, 0x3c, 0x80]);
const avcC = buildAvcC(sps, pps);

// 11 fixed bytes + full sps + full pps (not sps.length - 1 / pps.length - 1)
expect(avcC.length).toBe(11 + sps.length + pps.length);

expect(avcC[0]).toBe(1); // configurationVersion
expect(avcC[1]).toBe(sps[1]); // AVCProfileIndication
expect(avcC[2]).toBe(sps[2]); // profile_compatibility
expect(avcC[3]).toBe(sps[3]); // AVCLevelIndication
expect(avcC[4]).toBe(0xff); // lengthSizeMinusOne=3 (4-byte lengths)
expect(avcC[5]).toBe(0xe1); // numOfSequenceParameterSets=1

const spsLen = (avcC[6] << 8) | avcC[7];
expect(spsLen).toBe(sps.length);
const embeddedSps = avcC.slice(8, 8 + spsLen);
expect(embeddedSps).toEqual(sps); // full NAL, header byte (0x67) included

let o = 8 + spsLen;
expect(avcC[o]).toBe(1); // numOfPictureParameterSets
o += 1;
const ppsLen = (avcC[o] << 8) | avcC[o + 1];
expect(ppsLen).toBe(pps.length);
o += 2;
const embeddedPps = avcC.slice(o, o + ppsLen);
expect(embeddedPps).toEqual(pps); // full NAL, header byte (0x68) included
});
});

describe("codecFromSps", () => {
it("builds an avc1.PPCCLL codec string from profile/compat/level bytes", () => {
const sps = new Uint8Array([0x67, 0x42, 0x00, 0x1e]);
expect(codecFromSps(sps)).toBe("avc1.42001e");
});

it("pads single hex digits with a leading zero", () => {
const sps = new Uint8Array([0x67, 0x0a, 0x00, 0x09]);
expect(codecFromSps(sps)).toBe("avc1.0a0009");
});
});

describe("readPtsUs", () => {
// Wire format [type][pts_us][payload]: pts_us is 8-byte big-endian at offset 1.
it("reads an 8-byte big-endian pts_us starting at offset 1", () => {
const data = new Uint8Array(kFrameHeaderLen + 2);
data[0] = kTypeVideo;
// pts_us = 0x0000000000033333 (209715 decimal, arbitrary test value)
const view = new DataView(data.buffer);
view.setBigUint64(1, 0x33333n, false);
expect(readPtsUs(data)).toBe(0x33333);
});

it("round-trips values the control plane actually sends (pts_us up to ~a few minutes)", () => {
const ptsUs = 176_664_900; // observed real value, ~176s into a clip
const data = new Uint8Array(kFrameHeaderLen);
data[0] = kTypeAudio;
new DataView(data.buffer).setBigUint64(1, BigInt(ptsUs), false);
expect(readPtsUs(data)).toBe(ptsUs);
});

it("reads 0 correctly", () => {
const data = new Uint8Array(kFrameHeaderLen);
expect(readPtsUs(data)).toBe(0);
});
});

describe("wire format type markers", () => {
it("uses distinct, expected byte values for video and audio", () => {
expect(kTypeVideo).toBe(0x00);
expect(kTypeAudio).toBe(0x01);
expect(kTypeVideo).not.toBe(kTypeAudio);
});

it("header length accounts for 1 type byte + 8 pts bytes", () => {
expect(kFrameHeaderLen).toBe(9);
});
});
Loading
Loading