Skip to content
Open
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
8 changes: 7 additions & 1 deletion js/hang/src/catalog/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const AudioConfigSchema = z.object({
codec: z.string(),

// Container format for timestamp encoding
// Defaults to "legacy" when not specified in catalog (backward compatibility)
// Defaults to "native" when not specified in catalog (backward compatibility)
container: ContainerSchema.default(DEFAULT_CONTAINER),

// The description is used for some codecs.
Expand All @@ -32,6 +32,12 @@ export const AudioConfigSchema = z.object({
// The bitrate of the audio in bits per second
// TODO: Support up to Number.MAX_SAFE_INTEGER
bitrate: u53Schema.optional(),

// Init segment (ftyp+moov) for CMAF/fMP4 containers.
// This is the initialization segment needed for MSE playback.
// Stored as base64-encoded bytes. If not provided, init segments
// will be sent over the data track (legacy behavior).
initSegment: z.string().optional(), // base64-encoded
});

export const AudioSchema = z
Expand Down
10 changes: 5 additions & 5 deletions js/hang/src/catalog/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ import { z } from "zod";
/**
* Container format for frame timestamp encoding.
*
* - "legacy": Uses QUIC VarInt encoding (1-8 bytes, variable length)
* - "native": Uses QUIC VarInt encoding (1-8 bytes, variable length)
* - "raw": Uses fixed u64 encoding (8 bytes, big-endian)
* - "fmp4": Fragmented MP4 container (future)
* - "cmaf": Fragmented MP4 container (future)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update stale comment about CMAF availability.

The comment indicates CMAF is "(future)", but the implementation in js/hang/src/container/codec.ts (lines 18-21, 45-52) and usage in MSE source files show it's already active.

📝 Suggested fix
-- "cmaf": Fragmented MP4 container (future)
+- "cmaf": Fragmented MP4 container with moof+mdat fragments
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* - "cmaf": Fragmented MP4 container (future)
* - "cmaf": Fragmented MP4 container with moof+mdat fragments
🤖 Prompt for AI Agents
In @js/hang/src/catalog/container.ts at line 8, Update the stale inline comment
entry for "cmaf" in the container list so it no longer says "(future)"; locate
the "cmaf" item in js/hang/src/catalog/container.ts and change its annotation to
reflect that CMAF is supported/active (consistent with the implementation in
container/codec.ts and MSE usage).

*/
export const ContainerSchema = z.enum(["legacy", "raw", "fmp4"]);
export const ContainerSchema = z.enum(["native", "raw", "cmaf"]);

export type Container = z.infer<typeof ContainerSchema>;

/**
* Default container format when not specified.
* Set to legacy for backward compatibility.
* Set to native for backward compatibility.
*/
export const DEFAULT_CONTAINER: Container = "legacy";
export const DEFAULT_CONTAINER: Container = "native";
8 changes: 7 additions & 1 deletion js/hang/src/catalog/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const VideoConfigSchema = z.object({
codec: z.string(),

// Container format for timestamp encoding
// Defaults to "legacy" when not specified in catalog (backward compatibility)
// Defaults to "native" when not specified in catalog (backward compatibility)
container: ContainerSchema.default(DEFAULT_CONTAINER),

// The description is used for some codecs.
Expand Down Expand Up @@ -43,6 +43,12 @@ export const VideoConfigSchema = z.object({
// If true, the decoder will optimize for latency.
// Default: true
optimizeForLatency: z.boolean().optional(),

// Init segment (ftyp+moov) for CMAF/fMP4 containers.
// This is the initialization segment needed for MSE playback.
// Stored as base64-encoded bytes. If not provided, init segments
// will be sent over the data track (legacy behavior).
initSegment: z.string().optional(), // base64-encoded
});

// Mirrors VideoDecoderConfig
Expand Down
21 changes: 12 additions & 9 deletions js/hang/src/container/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import type * as Time from "../time";
*/
export function encodeTimestamp(timestamp: Time.Micro, container: Catalog.Container = DEFAULT_CONTAINER): Uint8Array {
switch (container) {
case "legacy":
case "native":
return encodeVarInt(timestamp);
case "raw":
return encodeU64(timestamp);
case "fmp4":
throw new Error("fmp4 container not yet implemented");
case "cmaf": {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be encoding the timestamp here. The entire frame payload should be a fMP4 fragment.

// CMAF fragments contain timestamps in moof atoms, no header needed
return new Uint8Array(0);
}
}
}

Expand All @@ -32,16 +34,17 @@ export function decodeTimestamp(
container: Catalog.Container = DEFAULT_CONTAINER,
): [Time.Micro, Uint8Array] {
switch (container) {
case "legacy": {
case "native": {
const [value, remaining] = decodeVarInt(buffer);
return [value as Time.Micro, remaining];
}
case "raw": {
const [value, remaining] = decodeU64(buffer);
return [value as Time.Micro, remaining];
}
case "fmp4":
throw new Error("fmp4 container not yet implemented");
case "cmaf": {
return [0 as Time.Micro, buffer];
}
}
}

Expand All @@ -54,12 +57,12 @@ export function decodeTimestamp(
*/
export function getTimestampSize(container: Catalog.Container = DEFAULT_CONTAINER): number {
switch (container) {
case "legacy":
case "native":
return 8; // VarInt maximum size
case "raw":
return 8; // u64 fixed size
case "fmp4":
throw new Error("fmp4 container not yet implemented");
case "cmaf":
return 8; // VarInt maximum size (same as native)
}
}

Expand Down
33 changes: 23 additions & 10 deletions js/hang/src/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ export function encode(source: Uint8Array | Source, timestamp: Time.Micro, conta
// Encode timestamp using the specified container format
const timestampBytes = Container.encodeTimestamp(timestamp, container);

// For CMAF, timestampBytes will be empty, so we just return the source
if (container === "cmaf") {
if (source instanceof Uint8Array) {
return source;
}
const data = new Uint8Array(source.byteLength);
source.copyTo(data);
return data;
}

// Allocate buffer for timestamp + payload
const payloadSize = source instanceof Uint8Array ? source.byteLength : source.byteLength;
const data = new Uint8Array(timestampBytes.byteLength + payloadSize);
Expand Down Expand Up @@ -112,19 +122,18 @@ export class Consumer {

async #run() {
// Start fetching groups in the background

for (;;) {
const consumer = await this.#track.nextGroup();
if (!consumer) break;
if (!consumer) {
break;
}

// To improve TTV, we always start with the first group.
// For higher latencies we might need to figure something else out, as its racey.
if (this.#active === undefined) {
this.#active = consumer.sequence;
}

if (consumer.sequence < this.#active) {
console.warn(`skipping old group: ${consumer.sequence} < ${this.#active}`);
// Skip old groups.
consumer.close();
continue;
}
Expand All @@ -150,7 +159,9 @@ export class Consumer {

for (;;) {
const next = await group.consumer.readFrame();
if (!next) break;
if (!next) {
break;
}

const { data, timestamp } = decode(next, this.#container);
const frame = {
Expand Down Expand Up @@ -223,8 +234,6 @@ export class Consumer {
if (this.#active !== undefined && first.consumer.sequence <= this.#active) {
this.#groups.shift();

console.warn(`skipping slow group: ${first.consumer.sequence} < ${this.#groups[0]?.consumer.sequence}`);

first.consumer.close();
first.frames.length = 0;
}
Expand All @@ -246,7 +255,9 @@ export class Consumer {
this.#groups[0].consumer.sequence <= this.#active
) {
const frame = this.#groups[0].frames.shift();
if (frame) return frame;
if (frame) {
return frame;
}

// Check if the group is done and then remove it.
if (this.#active > this.#groups[0].consumer.sequence) {
Expand All @@ -261,7 +272,9 @@ export class Consumer {

const wait = new Promise<void>((resolve) => {
this.#notify = resolve;
}).then(() => true);
}).then(() => {
return true;
});

if (!(await Promise.race([wait, this.#signals.closed]))) {
this.#notify = undefined;
Expand Down
52 changes: 52 additions & 0 deletions js/hang/src/util/mime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type * as Catalog from "../catalog";

/**
* Builds a MIME type string for MediaSource from a codec string.
*
* @param codec - The codec string from the catalog (e.g., "avc1.42E01E", "mp4a.40.2")
* @param type - "video" or "audio"
* @returns MIME type string (e.g., "video/mp4; codecs=\"avc1.42E01E\"")
*/
export function buildMimeType(codec: string, type: "video" | "audio"): string {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very MP4 specific, but it has a generic name.

// For MP4 containers, we use the standard MIME type format
// Most codecs are already in the correct format for MSE
return `${type}/mp4; codecs="${codec}"`;
}

/**
* Checks if a MIME type is supported by MediaSource.
*
* @param mimeType - The MIME type to check
* @returns true if supported, false otherwise
*/
export function isMimeTypeSupported(mimeType: string): boolean {
return MediaSource.isTypeSupported(mimeType);
}
Comment on lines +22 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate the file and understand its context
find . -type f -name "mime.ts" | head -20

Repository: moq-dev/moq

Length of output: 81


🏁 Script executed:

# Search for imports of isMimeTypeSupported to see where it's used
rg "isMimeTypeSupported" --type ts --type js -B 2 -A 2

Repository: moq-dev/moq

Length of output: 1004


🏁 Script executed:

# Check if this module is imported in test files or SSR contexts
rg "from.*mime|import.*mime" --type ts --type js | grep -E "(test|spec|ssr|server)" | head -20

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Check the overall structure of the mime.ts file
cat -n js/hang/src/util/mime.ts

Repository: moq-dev/moq

Length of output: 2076


🏁 Script executed:

# Search for other uses of MediaSource in the codebase
rg "MediaSource" --type ts --type js -B 1 -A 1

Repository: moq-dev/moq

Length of output: 5428


🏁 Script executed:

# Check if mime.ts or any functions from it are imported in test files
find . -type f \( -name "*.test.ts" -o -name "*.spec.ts" -o -name "*.test.js" -o -name "*.spec.js" \) | xargs grep -l "mime" 2>/dev/null || echo "No test files found importing mime utilities"

Repository: moq-dev/moq

Length of output: 99


🏁 Script executed:

# Check the project structure to understand if this is browser-only or if it can be run in Node
head -30 package.json

Repository: moq-dev/moq

Length of output: 442


🏁 Script executed:

# Look for any SSR or Node-related imports or configurations
find . -name "*.config.*" -o -name "tsconfig*.json" | head -10

Repository: moq-dev/moq

Length of output: 335


🏁 Script executed:

# Check if there are any existing guards for browser APIs in the codebase
rg "typeof.*!==.*undefined|globalThis\.|typeof.*global" --type ts --type js | head -20

Repository: moq-dev/moq

Length of output: 2011


🏁 Script executed:

# Check where buildVideoMimeType and buildAudioMimeType are used
rg "buildVideoMimeType|buildAudioMimeType" --type ts --type js -B 2 -A 2

Repository: moq-dev/moq

Length of output: 1585


🏁 Script executed:

# Check if mime.ts is a browser-only utility or if it might be loaded in Node contexts
find . -type f \( -name "package.json" -o -name "tsconfig.json" \) -path "*/js/hang/*" | xargs cat | grep -E "target|module|browser|exports" | head -20

Repository: moq-dev/moq

Length of output: 87


🏁 Script executed:

# Check the vite and vitest configs to understand the build target
cat js/hang-demo/vite.config.ts

Repository: moq-dev/moq

Length of output: 724


🏁 Script executed:

# Double-check if there's explicit browser/non-browser context configuration
cat js/hang/package.json | head -50

Repository: moq-dev/moq

Length of output: 1428


Guard MediaSource access to prevent crashes in non-browser contexts.

The function directly accesses MediaSource.isTypeSupported() without checking if MediaSource exists. Since this utility is publicly exported and could be imported in non-browser environments (Node.js, tests, SSR), it will throw ReferenceError: MediaSource is not defined. The codebase already uses this guard pattern extensively for browser APIs (e.g., WebTransport, AudioContext, CaptureController).

Proposed fix
 export function isMimeTypeSupported(mimeType: string): boolean {
-	return MediaSource.isTypeSupported(mimeType);
+	return typeof MediaSource !== "undefined" && MediaSource.isTypeSupported(mimeType);
 }
🤖 Prompt for AI Agents
In @js/hang/src/util/mime.ts around lines 22 - 24, isMimeTypeSupported currently
calls MediaSource.isTypeSupported directly and will throw in non-browser
environments; guard access by checking that typeof MediaSource !== "undefined"
and that MediaSource.isTypeSupported is a function before calling it, returning
false when the guard fails; update the isMimeTypeSupported function to perform
this safe check so imports in Node/SSR/tests do not trigger ReferenceError.


/**
* Builds and validates a MIME type for video from catalog config.
*
* @param config - Video configuration from catalog
* @returns MIME type string or undefined if not supported
*/
export function buildVideoMimeType(config: Catalog.VideoConfig): string | undefined {
const mimeType = buildMimeType(config.codec, "video");
if (isMimeTypeSupported(mimeType)) {
return mimeType;
}
return undefined;
}

/**
* Builds and validates a MIME type for audio from catalog config.
*
* @param config - Audio configuration from catalog
* @returns MIME type string or undefined if not supported
*/
export function buildAudioMimeType(config: Catalog.AudioConfig): string | undefined {
const mimeType = buildMimeType(config.codec, "audio");
if (isMimeTypeSupported(mimeType)) {
return mimeType;
}
return undefined;
}
43 changes: 41 additions & 2 deletions js/hang/src/watch/audio/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export class Emitter {
});

this.#signals.effect((effect) => {
const enabled = !effect.get(this.paused) && !effect.get(this.muted);
const paused = effect.get(this.paused);
const enabled = !paused;
this.source.enabled.set(enabled);
});

Expand All @@ -56,7 +57,44 @@ export class Emitter {
this.muted.set(volume === 0);
});

// Handle MSE path (HTMLAudioElement) vs WebCodecs path (AudioWorklet)
this.#signals.effect((effect) => {
const mseAudio = effect.get(this.source.mseAudioElement);
if (mseAudio) {
// MSE path: control HTMLAudioElement directly
effect.effect(() => {
const volume = effect.get(this.volume);
const muted = effect.get(this.muted);
const paused = effect.get(this.paused);
mseAudio.volume = volume;
mseAudio.muted = muted;

// Control play/pause state
if (paused && !mseAudio.paused) {
mseAudio.pause();
} else if (!paused && mseAudio.paused) {
// Resume if paused - try to play even if readyState is low
const tryPlay = () => {
if (!paused && mseAudio.paused) {
mseAudio
.play()
.catch((err) => console.error("[Audio Emitter] Failed to resume audio:", err));
}
};

// Try to play if we have metadata (HAVE_METADATA = 1), browser will start when ready
if (mseAudio.readyState >= HTMLMediaElement.HAVE_METADATA) {
tryPlay();
} else {
// Wait for loadedmetadata event if not ready yet
mseAudio.addEventListener("loadedmetadata", tryPlay, { once: true });
}
Comment on lines +85 to +91
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential memory leak: event listener not cleaned up.

When mseAudio.readyState < HAVE_METADATA, a loadedmetadata event listener is added with { once: true }. However, if the effect is cleaned up before the event fires (e.g., the component unmounts or the effect re-runs), the listener remains attached to the element.

Consider cleaning up the event listener in the effect's cleanup phase:

Proposed fix
 					// Try to play if we have metadata (HAVE_METADATA = 1), browser will start when ready
 					if (mseAudio.readyState >= HTMLMediaElement.HAVE_METADATA) {
 						tryPlay();
 					} else {
 						// Wait for loadedmetadata event if not ready yet
 						mseAudio.addEventListener("loadedmetadata", tryPlay, { once: true });
+						effect.cleanup(() => mseAudio.removeEventListener("loadedmetadata", tryPlay));
 					}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @js/hang/src/watch/audio/emitter.ts around lines 86 - 92, When readyState is
below HTMLMediaElement.HAVE_METADATA you add a loadedmetadata listener via
mseAudio.addEventListener("loadedmetadata", tryPlay, { once: true }) but never
remove it if the effect unmounts or re-runs; update the effect to capture the
handler reference (e.g., const onLoaded = tryPlay), attach with
mseAudio.addEventListener("loadedmetadata", onLoaded, { once: true }), and in
the effect cleanup call mseAudio.removeEventListener("loadedmetadata", onLoaded)
(guarding that mseAudio still exists and its readyState is still <
HAVE_METADATA) so the listener is removed if the component unmounts or the
effect is disposed before the event fires.

}
});
return;
}

// WebCodecs path: use AudioWorklet with GainNode
const root = effect.get(this.source.root);
if (!root) return;

Expand All @@ -76,9 +114,10 @@ export class Emitter {
});
});

// Only apply gain transitions for WebCodecs path (when gain node exists)
this.#signals.effect((effect) => {
const gain = effect.get(this.#gain);
if (!gain) return;
if (!gain) return; // MSE path doesn't use gain node

// Cancel any scheduled transitions on change.
effect.cleanup(() => gain.gain.cancelScheduledValues(gain.context.currentTime));
Expand Down
Loading