Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ __pycache__/

# Generated JSR manifest (derived from package.json by js/common/package.ts)
jsr.json
.claude/launch.json
18 changes: 11 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 14 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion demo/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import tailwindcss from "@tailwindcss/vite";
import { resolve } from "path";
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import { moqWasm } from "../../js/common/vite-plugin-wasm";
import { workletInline } from "../../js/common/vite-plugin-worklet";
import { consoleOverlay } from "./console-overlay";

export default defineConfig({
root: "src",
envDir: resolve(__dirname),
plugins: [tailwindcss(), solidPlugin(), workletInline(), consoleOverlay()],
plugins: [moqWasm(), tailwindcss(), solidPlugin(), workletInline(), consoleOverlay()],
build: {
target: "esnext",
sourcemap: process.env.NODE_ENV === "production" ? false : "inline",
Expand Down
60 changes: 60 additions & 0 deletions js/common/vite-plugin-wasm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { Plugin } from "vite";

// js/common -> repo root
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const dist = join(repoRoot, "js/wasm/dist/moq.js");

// Rebuild when the Rust sources behind the bindings change.
const watchDirs = ["rs/moq-wasm/src", "rs/moq-net/src"].map((d) => join(repoRoot, d)).filter(existsSync);

function newestMtime(dir: string): number {
let max = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
max = Math.max(max, entry.isDirectory() ? newestMtime(p) : statSync(p).mtimeMs);
}
return max;
}

function build(): void {
// `just wasm` = cargo build (wasm32) + wasm-bindgen into js/wasm/dist. Needs the
// nix dev shell on PATH, which `just dev` already provides.
execFileSync("just", ["wasm"], { cwd: repoRoot, stdio: "inherit" });
}

function buildIfStale(): void {
const distTime = existsSync(dist) ? statSync(dist).mtimeMs : 0;
const srcTime = watchDirs.length ? Math.max(...watchDirs.map(newestMtime)) : 0;
if (srcTime > distTime) build();
}

/**
* Builds `@moq/wasm` (the wasm-bindgen output in `js/wasm/dist`) on demand, so a
* consumer never has to run `just wasm` first. Rebuilds and full-reloads when the
* `rs/moq-wasm` / `rs/moq-net` sources change.
*/
export function moqWasm(): Plugin {
return {
name: "moq-wasm",
enforce: "pre",
buildStart() {
buildIfStale();
},
configureServer(server) {
for (const d of watchDirs) server.watcher.add(d);
server.watcher.on("change", (file) => {
if (!watchDirs.some((d) => file.startsWith(d))) return;
try {
build();
server.ws.send({ type: "full-reload" });
} catch (err) {
server.config.logger.error(`moq-wasm rebuild failed: ${String(err)}`);
}
});
},
};
}
137 changes: 137 additions & 0 deletions js/hang/src/container/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { expect, test } from "bun:test";
import { type Time, Varint } from "@moq/net";
import type { InitSegment } from "./cmaf/decode.ts";
import { encodeDataSegment } from "./cmaf/encode.ts";
import { Format as CmafFormat } from "./cmaf/format.ts";
import { Format as LegacyFormat } from "./legacy.ts";

const TIMESCALE = 90_000;
const TEST_INIT: InitSegment = {
timescale: TIMESCALE,
trackId: 1,
defaultSampleDuration: 0,
defaultSampleSize: 0,
defaultSampleFlags: 0,
};

function encodeLegacyFrame(timestamp: Time.Micro, payload: Uint8Array): Uint8Array {
const tsBytes = Varint.encode(timestamp);
const data = new Uint8Array(tsBytes.byteLength + payload.byteLength);
data.set(tsBytes, 0);
data.set(payload, tsBytes.byteLength);
return data;
}

// --- LegacyFormat ---

test("LegacyFormat decodes a valid frame", () => {
const format = new LegacyFormat();
const payload = new Uint8Array([0xde, 0xad]);
const timestamp = 1000 as Time.Micro;
const frame = encodeLegacyFrame(timestamp, payload);

const result = format.decode(frame);

expect(result).toHaveLength(1);
expect(result[0].timestamp).toBe(timestamp);
expect(result[0].data).toEqual(payload);
expect(result[0].keyframe).toBe(false);
});

test("LegacyFormat always returns keyframe: false", () => {
const format = new LegacyFormat();
const frame = encodeLegacyFrame(0 as Time.Micro, new Uint8Array([0x01]));

const [decoded] = format.decode(frame);
expect(decoded.keyframe).toBe(false);
});

test("LegacyFormat always returns exactly one frame", () => {
const format = new LegacyFormat();
const frame = encodeLegacyFrame(5000 as Time.Micro, new Uint8Array([0x01, 0x02, 0x03]));

const result = format.decode(frame);
expect(result).toHaveLength(1);
});

test("LegacyFormat throws on empty input", () => {
const format = new LegacyFormat();
expect(() => format.decode(new Uint8Array(0))).toThrow();
});

test("LegacyFormat throws on truncated input", () => {
const format = new LegacyFormat();
// A varint that indicates more bytes follow but is truncated
expect(() => format.decode(new Uint8Array([0x80]))).toThrow();
});

// --- CmafFormat ---

test("CmafFormat decodes a valid keyframe segment", () => {
const format = new CmafFormat(TEST_INIT);
const segment = encodeDataSegment({
data: new Uint8Array([0xca, 0xfe]),
timestamp: 0,
duration: 3000,
keyframe: true,
sequence: 0,
});

const result = format.decode(segment);

expect(result).toHaveLength(1);
expect(result[0].data).toEqual(new Uint8Array([0xca, 0xfe]));
expect(result[0].timestamp).toBe(0 as Time.Micro);
expect(result[0].keyframe).toBe(true);
});

test("CmafFormat decodes a delta frame segment", () => {
const format = new CmafFormat(TEST_INIT);
const segment = encodeDataSegment({
data: new Uint8Array([0xbe, 0xef]),
timestamp: 3000,
duration: 3000,
keyframe: false,
sequence: 1,
});

const result = format.decode(segment);

expect(result).toHaveLength(1);
expect(result[0].keyframe).toBe(false);
});

test("CmafFormat converts timescale units to microseconds", () => {
const format = new CmafFormat(TEST_INIT);
// 90000 timescale units = 1 second = 1_000_000 microseconds
const segment = encodeDataSegment({
data: new Uint8Array([0x01]),
timestamp: TIMESCALE,
duration: 3000,
keyframe: true,
sequence: 0,
});

const result = format.decode(segment);
expect(result[0].timestamp).toBe(1_000_000 as Time.Micro);
});

test("CmafFormat throws on corrupt segment", () => {
const format = new CmafFormat(TEST_INIT);
expect(() => format.decode(new Uint8Array([0x00, 0x01, 0x02]))).toThrow();
});

test("CmafFormat decodes the per-sample duration", () => {
const format = new CmafFormat(TEST_INIT);
const segment = encodeDataSegment({
data: new Uint8Array([0xca, 0xfe]),
timestamp: 0,
duration: 3000,
keyframe: true,
sequence: 0,
});

const [frame] = format.decode(segment);
// 3000 ticks / 90000 timescale * 1_000_000 = 33333µs
expect(frame.duration).toBe(33_333 as Time.Micro);
});
1 change: 0 additions & 1 deletion js/hang/src/container/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

export * as Loc from "@moq/loc";
export * as Cmaf from "./cmaf";
export { Consumer, type ConsumerProps } from "./consumer";
export type { Format } from "./format";
export * as Legacy from "./legacy";
export * from "./types";
Loading
Loading